⚡ 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):

Azure Functions

From Microsoft's documentation: the hosting plan decides scaling, resources and cost.

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.

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

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

  1. Stateless. Anything that must survive goes to a database, object storage or a queue.
  2. Idempotent. Safe to run twice for the same event.
  3. Short. Do one thing. Split long work into steps.
  4. Initialize once, outside the handler, and keep per-request data inside it.
  5. Time budget below the caller's. A function that needs 25 seconds behind a 30-second gateway is one slow database away from errors.
  6. Handle bad input with a clear error, so the platform and the caller do not retry something that can never succeed.
  7. Least privilege per function, not one powerful role for all. A function is an entry point: treat its permissions like a firewall rule.
  8. No secrets in code or environment variables you commit. Read them from the secrets service using the function's identity.
  9. Log to the platform's logging, with a request ID, and alert on errors and on throttling.
  10. 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.
  11. 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:

Common mistakes

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

← DNS and Domain Names Across the Clouds · ↑ All tutorials · Moving Your First Workload to the Cloud: Inventory, Sync, Cutover and Rollback →