⚡ Serverless Functions on Every Cloud: Handlers, Events and Limits
Running a small piece of code only when something happens: how Lambda, Azure Functions, Cloud Run functions and Oracle Functions differ, how warm and cold starts really behave, why duplicates and timeouts are your problem, and how to keep the logic testable on your own machine
💡 How this page was tested. What I could run for real without a cloud account was run on 6 October 2026: a small "order pricing" function with its logic separated from the platform wrapper, seven unit tests (all pass), two platform wrappers run with fake requests (the AWS and the Google Cloud shapes), and a toy "platform" that shows how a warm environment keeps state while cold ones start fresh, how duplicate events slip past in-memory de-duplication, and what a timeout looks like. The toy platform is my own model, not any provider's engine. The wrappers for Azure and Oracle and all deploy commands are not run here: those SDKs were not installed, so those shapes are from the platforms' usual quickstarts, and you should check the current documentation. The limits, plans and behaviour of each service are from the providers' documentation, read on 6 October 2026. Related: containers (Cloud Run and Container Apps), IAM, cost control and the security baseline.
What a serverless function is
You write one function. You tell the cloud what should trigger it: a web request, a file arriving in storage, a message in a queue, a timer. The cloud runs your code when the trigger fires, scales it from zero to many copies, and bills for the time it actually runs. You never see a server.
It suits short, event-driven work: a small API, resizing an uploaded image, reacting to a queue message, a nightly cleanup. It fits badly with long jobs, big state in memory, and anything that must hold a connection open for hours. All four clouds offer it, with the same idea and different details.
The four services
AWS Lambda
From AWS's documentation (read on 6 October 2026):
- You write a handler, connect it to a trigger (API Gateway, S3, SQS, EventBridge and more than 200 other AWS services), and Lambda runs it. "Each invocation runs independently with no shared state, scaling horizontally to match demand."
- Up to 15 minutes per invocation. One request at a time per execution environment: ten simultaneous requests mean ten environments.
- Pricing: per request plus GB-seconds of execution time.
- Lifecycle (more in the next section): an Init phase (starting the runtime and running your code outside the handler, limited to 10 seconds), then Invoke, then Shutdown. After an invocation the environment is frozen and may be reused ("warm start"), but "state might not persist", and Lambda terminates environments every few hours even for a function that is invoked all the time.
- Cold starts (the first request to a new environment): AWS says they typically happen in under 1% of invocations and take from under 100 ms to over 1 second, are more common in development and test functions (invoked less often), and you are charged for that time. Provisioned concurrency pre-initializes environments for predictable start times, and SnapStart restores from a saved snapshot.
- Each environment has 512 MB to 10,240 MB of /tmp that stays between warm invocations: a transient cache, not storage.
Azure Functions
From Microsoft's documentation: the hosting plan decides scaling, resources and cost.
- Flex Consumption: the plan Microsoft tells you to use for new serverless function apps. Scales out fast, up to 1,000 instances, supports virtual network integration, always-ready instances to reduce cold starts, and instance sizes of 512 MB, 2,048 MB or 4,096 MB. Billing: executions, the memory of instances while they actively run functions, plus any always-ready instances. Linux only.
- Consumption is called legacy: Linux Consumption retires on 30 September 2028, and apps still on the end-of-life v3 runtime on Linux Consumption stop running after 30 September 2026.
- Premium (pre-warmed workers, always-on capacity, virtual networks, custom Linux images), Dedicated (an App Service plan, predictable cost) and Container Apps hosting (functions in a container, see the containers page).
- Timeouts: the default is 30 minutes on the plans above (5 on legacy Consumption, with a 10-minute maximum), but an HTTP-triggered function must respond within 230 seconds whatever you set, because of the Azure load balancer's idle timeout. For longer work, return quickly and finish in the background, or use Durable Functions.
Google Cloud: Cloud Run functions
From Google's documentation: "a function is a Cloud Run service that is deployed from source code": it is built into a container and runs on Cloud Run, which makes functions and the Cloud Run services of the containers page cousins.
- Two kinds: HTTP functions and CloudEvents (event-driven) functions. Runtimes: Node.js, Python, Go, Java, .NET, Ruby and PHP.
- Timeouts: up to 60 minutes for HTTP functions, and up to 9 minutes for event-driven functions created with the Cloud Functions v2 API.
- Concurrency: up to 1,000 concurrent requests per instance, and scale to zero when idle. (This is a real difference from Lambda's one-request-per-environment: your code must be safe for concurrent requests, and in-memory state is shared between them.)
- Deploying uses gcloud run deploy for functions created through the Cloud Run API, or gcloud functions deploy for the Cloud Functions v2 API.
Oracle Cloud: Functions
From Oracle's documentation: a serverless function platform built on the open-source Fn Project, fully managed and multi-tenant. Code-only functions (you provide an archive and choose a managed runtime, and Oracle manages runtime updates) or container-image functions (you control the image, packages, scanning and signing). Languages: Java, Python, Node, Go, Ruby and C#. Request and response payloads: 6 MB maximum each. It works with IAM and with Events (functions can be invoked by other OCI services), and billing is based on resources consumed during execution. (I could not read the page on timeouts, memory settings and cold-start options: check those before relying on them.)
Warm and cold, run for real
The most misunderstood part of functions is what survives between runs. Code outside the handler runs once per environment; code inside it runs on every request. The toy function:
import time, os
_t0 = time.time()
import sqlite3, decimal, json, hashlib # "static initialization": runs once per environment
CACHE = {}
INVOCATIONS = 0
PID = os.getpid()
INIT_MS = (time.time() - _t0) * 1000
def handler(event):
global INVOCATIONS
INVOCATIONS += 1
hit = event["key"] in CACHE
CACHE[event["key"]] = True
return "environment pid %d: invocation #%d, cache %s" % (PID, INVOCATIONS, "hit" if hit else "miss")
The imports and the two globals (CACHE, INVOCATIONS) are the "static initialization" that AWS describes: the place to open connections and load libraries, once. A tiny toy platform to compare reuse with fresh starts (my own model):
"""A toy 'platform': it reuses one environment for several requests (warm) and starts a new one when told (cold)."""
import subprocess, sys, time
def cold_invoke(key):
t = time.time()
out = subprocess.run([sys.executable, "-c", "import warm; print(warm.handler({'key': %r}), '| init %%.1f ms' %% warm.INIT_MS)" % key],
capture_output=True, text=True).stdout.strip()
return out, (time.time() - t) * 1000
if __name__ == "__main__":
print("--- one environment, four requests (warm reuse) ---")
import warm
for key in ("a", "b", "a", "a"):
print(warm.handler({"key": key}))
print("--- three separate environments (each is a cold start) ---")
for key in ("a", "a", "a"):
out, ms = cold_invoke(key)
print("%s | start-to-answer %.0f ms" % (out, ms))
Run (as run):
user@lab:~/fn$ python3 toyplatform.py --- one environment, four requests (warm reuse) --- environment pid 586053: invocation #1, cache miss environment pid 586053: invocation #2, cache miss environment pid 586053: invocation #3, cache hit environment pid 586053: invocation #4, cache hit --- three separate environments (each is a cold start) --- environment pid 586054: invocation #1, cache miss | init 21.9 ms | start-to-answer 62 ms environment pid 586055: invocation #1, cache miss | init 23.0 ms | start-to-answer 65 ms environment pid 586056: invocation #1, cache miss | init 21.8 ms | start-to-answer 68 ms
- One environment, four requests: the same process (same pid) counts invocations #1 to #4, and the second a is a cache hit. That is a warm start: global state survived.
- Three separate environments: each is a new process with invocation #1 and a cache miss: nothing survived. Each paid the initialization (about 20 ms of imports here, which is a small stand-in for the library loading, connections and configuration of a real function) and the start-to-answer time was about 60 ms.
- Lessons: put expensive setup outside the handler (a database connection, an SDK client, a loaded model) so warm requests skip it; never rely on state surviving (a cache is an optimization, not data); and do not keep per-request data in globals: AWS explicitly says to avoid global variables for context-specific information, and on a platform with concurrent requests (Google Cloud) a global changed by one request is seen by the others.
Duplicates and timeouts
At-least-once delivery
Event sources (queues, storage notifications, retries) generally promise at least once, not exactly once: the same event can arrive twice, sometimes at different environments. A function that charges a customer must survive that. The naive fix, remembering seen event IDs in memory, is shown here:
"""At-least-once delivery: the platform may deliver the same event twice, possibly to different environments."""
import subprocess, sys
SCRIPT = """
SEEN = set() # in-memory de-duplication: lives only in THIS environment
def handle(event):
if event['id'] in SEEN: return 'skipped duplicate ' + event['id']
SEEN.add(event['id']); return 'charged customer for ' + event['id']
print(handle({'id': 'evt-7'})); print(handle({'id': 'evt-7'}))
"""
for n in (1, 2):
print('environment %d:' % n)
print(subprocess.run([sys.executable, "-c", SCRIPT], capture_output=True, text=True).stdout.rstrip())
and its result (as run):
user@lab:~/fn$ python3 dup.py environment 1: charged customer for evt-7 skipped duplicate evt-7 environment 2: charged customer for evt-7 skipped duplicate evt-7 user@lab:~/fn$ python3 slow.py platform: handler exceeded its 1.5 second timeout after 1.5 s: killed, the caller sees an error
Within one environment the duplicate is caught ("skipped duplicate"), but a second environment charges the customer again for the same event: in-memory de-duplication only works inside one environment. Make functions idempotent (doing the work twice gives the same result as once) and keep a de-duplication record in shared storage (a database row with a unique key, a conditional write): "insert this event ID, and if it already exists, stop."
Timeouts
The last lines of that output show what a timeout looks like: the toy platform killed a handler that ran past its limit (1.5 seconds), and the caller sees an error, not a half-finished success. On a real platform you set the timeout on the function, and the caller may have a shorter one (Azure's 230-second HTTP limit is an example). Design for it: do less per invocation, split long work into steps connected by queues, and make each step safe to repeat, because a timed-out run may have done part of its work.
Keep the logic testable: a core and a wrapper
Cloud wrappers (the event and context of Lambda, the request object of Google Cloud, a decorated function on Azure) are awkward to run on your laptop. The fix is old and good: put the logic in a plain function that knows nothing about any cloud, and make each platform's handler a thin adapter. The core:
"""The business logic: no cloud, no framework, easy to test."""
import json
def price_order(order):
if not isinstance(order, dict) or "items" not in order:
raise ValueError("order needs an 'items' list")
total = 0
for item in order["items"]:
qty = int(item.get("qty", 1))
if qty < 1:
raise ValueError("quantity must be at least 1")
total += qty * int(item["cents"])
return {"order_id": order.get("id", "unknown"), "total_cents": total}
And the two adapters I could run, one per platform shape: AWS Lambda behind API Gateway (the "proxy" format: an event with a body in, a dictionary with statusCode and body out) and Google Cloud (a request object in, a body, status and headers out):
"""One adapter per platform: translate that platform's request/response shape to the core."""
import json
from orders import price_order
# AWS Lambda behind an API Gateway "proxy" integration: (event, context) -> dict
def aws_handler(event, context=None):
try:
result = price_order(json.loads(event.get("body") or "{}"))
return {"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": json.dumps(result)}
except (ValueError, KeyError, json.JSONDecodeError) as e:
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
# Google Cloud (Functions Framework style): request object -> (body, status, headers)
def gcp_handler(request):
try:
return (json.dumps(price_order(request.get_json(silent=True))), 200, {"Content-Type": "application/json"})
except (ValueError, KeyError) as e:
return (json.dumps({"error": str(e)}), 400, {})
Seven tests, all run for real, against the core and against both adapters, using a fake request object for the platform's own:
user@lab:~/fn$ python3 test_orders.py test_aws_bad_json (__main__.Adapters.test_aws_bad_json) ... ok test_aws_ok (__main__.Adapters.test_aws_ok) ... ok test_gcp_bad (__main__.Adapters.test_gcp_bad) ... ok test_gcp_ok (__main__.Adapters.test_gcp_ok) ... ok test_bad_quantity (__main__.Core.test_bad_quantity) ... ok test_missing_items (__main__.Core.test_missing_items) ... ok test_total (__main__.Core.test_total) ... ok ---------------------------------------------------------------------- Ran 7 tests in 0.001s OK
All seven pass. This takes a fraction of a second and needs no cloud, no credentials and no cost, so it belongs in your build. And the AWS wrapper, called by hand with a good and a bad event:
user@lab:~/fn$ python3 -c "import json; from adapters import aws_handler; e={'httpMethod':'POST','body':json.dumps({'id':'A-100','items':[{'cents':250,'qty':2},{'cents':1000}]})}; print(json.dumps(aws_handler(e), indent=2))"
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"order_id\": \"A-100\", \"total_cents\": 1500}"
}
user@lab:~/fn$ python3 -c "from adapters import aws_handler; print(aws_handler({'body': '{oops'}))"
{'statusCode': 400, 'body': '{"error": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"}'}
The good event returns the proxy response: status 200, a header, and a body that is a string (JSON text inside JSON, which surprises people). The bad event, a body of {oops, returns 400 with the parser's message: a function should turn bad input into a clear client error, not a crash that the platform then retries.
The same core on Azure and Oracle (not run here)
The wrapper for Azure Functions (Python, the v2 programming model) and for Oracle Functions (the Python FDK) have the same job. Their usual shape (check the platforms' current quickstarts before copying):
# Azure Functions: a decorated function receives an HttpRequest
import json, azure.functions as func
from orders import price_order
app = func.FunctionApp()
@app.route(route="price")
def price(req: func.HttpRequest) -> func.HttpResponse:
try:
result = price_order(req.get_json())
return func.HttpResponse(json.dumps(result), mimetype="application/json")
except (ValueError, KeyError) as e:
return func.HttpResponse(json.dumps({"error": str(e)}), status_code=400)
# Oracle Functions: the FDK calls handler(ctx, data) with the body as bytes
import io, json
from fdk import response
from orders import price_order
def handler(ctx, data: io.BytesIO = None):
try:
result = price_order(json.loads(data.getvalue()))
return response.Response(ctx, response_data=json.dumps(result), headers={"Content-Type": "application/json"})
except (ValueError, KeyError) as e:
return response.Response(ctx, response_data=json.dumps({"error": str(e)}), status_code=400)
Four platforms, one core: that is the portability that matters.
Deploying (from the documentation, not run here)
The shape on each cloud (read --help and the quickstart for the current options):
# AWS: zip the code, create the function with an execution role, a runtime and a handler
aws lambda create-function --function-name price-order --runtime python3.13 \
--handler adapters.aws_handler --role ROLE_ARN --zip-file fileb://function.zip
# Azure: Azure Functions Core Tools publish the project to a function app
func azure functionapp publish APP_NAME
# Google Cloud: deploy from source (or gcloud functions deploy for the v2 API)
gcloud run deploy price-order --source . --function gcp_handler --region us-central1
# Oracle: the Fn Project CLI deploys a function into an application
fn deploy --app price-app
What each one needs besides the code: a runtime (language and version), an entry point (the handler name), an identity the function runs as (a role on AWS, a managed identity on Azure, a service account on Google Cloud, and on Oracle an identity for the function (check Oracle's documentation for how functions authenticate to other services): the IAM page) with only the permissions it needs, a memory size, a timeout, and a trigger.
Design rules
- Stateless. Anything that must survive goes to a database, object storage or a queue.
- Idempotent. Safe to run twice for the same event.
- Short. Do one thing. Split long work into steps.
- Initialize once, outside the handler, and keep per-request data inside it.
- Time budget below the caller's. A function that needs 25 seconds behind a 30-second gateway is one slow database away from errors.
- Handle bad input with a clear error, so the platform and the caller do not retry something that can never succeed.
- Least privilege per function, not one powerful role for all. A function is an entry point: treat its permissions like a firewall rule.
- No secrets in code or environment variables you commit. Read them from the secrets service using the function's identity.
- Log to the platform's logging, with a request ID, and alert on errors and on throttling.
- Mind the payload limits (Oracle: 6 MB each way; Azure HTTP: 210 MB request size per the limits table, but 230 seconds to answer): large data goes through storage, and the event carries the reference.
- Test locally with a core and thin adapters, then test in the cloud with a small real event.
What it costs, and the loops that cost a lot
Billing is per request and per time and memory used, so an idle function is almost free, and a busy one is cheap until something goes wrong:
- A function that triggers itself: it writes a file into the bucket that triggers it, or a message to the queue it reads. It can run millions of times in an hour. Use different buckets or prefixes for input and output, and set concurrency limits and budget alerts (the cost page: remember the alerts are hours late).
- Retries: a function that fails makes the platform retry, often several times, and each try bills. A bug can multiply the cost.
- Cold-start workarounds: provisioned concurrency (AWS) and always-ready instances (Azure) bill for being ready, whether used or not.
- Everything around the function: logs, API gateways, data transfer, the database it calls, and any NAT gateway needed so a function in a private network can reach the internet.
- Duration: memory and time are billed together, so a slow dependency is expensive: set tight timeouts on every call a function makes.
Common mistakes
- Relying on state surviving between invocations, or assuming it will not (globals are shared on warm starts and with concurrency).
- No idempotency: a duplicate delivery charges twice.
- Logic welded into the platform wrapper, so it cannot be tested without deploying.
- One huge function that does everything and has to be warmed up.
- A role with admin rights because "it was easier".
- Self-triggering loops, with no concurrency limit.
- Ignoring the caller's timeout, and silent partial failures after a timeout.
- Heavy dependencies, which lengthen every cold start.
- Using functions for what is not event-driven work: a long batch job or a constantly busy service is cheaper and simpler on a container or a VM.
What this page did not cover
API gateways and function URLs, orchestration (Step Functions, Durable Functions, Workflows, Oracle's workflow options), layers and packaging, streaming responses, function networking in a private network in detail, observability tooling, and running Lambda-style functions locally with the providers' emulators.
The cheat sheet
- Model: a handler plus a trigger. Pay per request and per time and memory. Scale from zero. No server to patch.
- Lambda: up to 15 minutes, one request per environment, 200+ triggers, Init limited to 10 s, /tmp 512 to 10,240 MB, provisioned concurrency and SnapStart for cold starts. Azure: Flex Consumption for new apps (up to 1,000 instances, always-ready instances), Consumption is legacy, HTTP answers within 230 s. Google Cloud: a function is a Cloud Run service, HTTP up to 60 min, events up to 9 min, 1,000 concurrent requests per instance. Oracle: Fn Project, code-only or container functions, 6 MB payloads each way.
- Warm vs cold: code outside the handler runs once per environment (warm keeps it, cold repeats it). Never trust state to survive. Do not keep request data in globals.
- Duplicates: at-least-once delivery. Make the work idempotent. De-duplicate in shared storage, not memory.
- Timeouts: set one, keep it below the caller's, split long work, make every step repeatable.
- Structure: a plain core function plus a thin adapter per platform. Unit-test the core and the adapters locally.
- Safety: least privilege per function, secrets from a secrets service, small payloads, input validation, no self-triggering loops, concurrency limits, budget alerts.
← DNS and Domain Names Across the Clouds · ↑ All tutorials · Moving Your First Workload to the Cloud: Inventory, Sync, Cutover and Rollback →