AWS Lambda and Serverless Basics
What serverless and Lambda actually are: the handler, event and context, memory/CPU coupling, timeouts, concurrency, triggers, cold starts, IAM roles, and honest limits.
Serverless gets sold as magic and explained as jargon, and both make it harder than it is. Strip it down and Lambda is one idea: you hand AWS a function, tell it what event should run that function, and AWS runs your code for the duration of that event and bills you for exactly that time. No servers to patch, no capacity to plan, no process to keep alive. When nothing calls it, it costs nothing. This guide builds that mental model, then walks the execution model, the triggers, cold starts, the IAM role, packaging, and - just as important - the workloads where Lambda is the wrong tool.
What "serverless" actually means
There are still servers. "Serverless" means you never see, provision, or manage them. You do not pick an instance type, size a fleet, install an OS, or run an autoscaler. You give the provider a unit of code, and the provider finds a machine, runs it, and takes it away when it is done.
Three properties fall out of that, and they are the whole pitch:
- No servers to manage. No SSH, no OS patching, no capacity planning. The platform owns the compute.
- Pay per invocation. You are billed per request and per millisecond of run time (rounded, times the memory you configured). Idle costs nothing.
- Scales to zero, and up on its own. No traffic means no running instances and no bill. A burst of traffic means the platform runs more copies of your function in parallel, automatically, with no scaling policy to write.
That last point is the real difference from a normal server. A VM you rent runs (and bills) 24/7 whether or not anyone hits it, and it handles more load only if you built autoscaling. A Lambda function runs only while an event is being processed, and concurrency is handled for you.
What Lambda is: a function triggered by an event
AWS Lambda is the compute service that runs your code in response to an event. You upload a function; you connect it to an event source (an HTTP request, a file landing in S3, a message on a queue, a timer); and every time that event fires, Lambda runs your function once, passing it the event's data.
That is the entire model. There is no long-running process you start and stop. There is a function, and there are events that invoke it. Everything else - scaling, availability, the underlying machine - is the platform's problem.
Here is a minimal Node.js function. The exported handler is the entry point AWS calls:
// index.mjs
export const handler = async (event, context) => {
const name = event.name || "world";
return {
statusCode: 200,
body: JSON.stringify({ message: `Hello, ${name}` }),
};
};
That is a complete, deployable Lambda. No web server, no port to bind, no main loop. AWS calls handler, hands it the event, and returns whatever you return to whatever triggered it.
The execution model
The details that trip people up all live in how Lambda runs that handler.
The handler, the event, and the context. Lambda calls one exported function per invocation. It gets two arguments: the event (the input - the parsed HTTP request, the S3 object metadata, the queue message, whatever the trigger sends) and the context (runtime metadata - the request ID, the function name, and crucially getRemainingTimeInMillis(), how long you have before the timeout). You read the event, do your work, and return a result (or throw an error).
Memory and CPU are coupled. You do not set CPU directly. You set memory (from 128 MB up to 10,240 MB), and CPU is allocated proportionally - more memory means more CPU (and around 1,769 MB you get roughly a full vCPU). This is the single most useful tuning knob: a CPU-bound function can finish faster at higher memory, and because you pay for time, a function that costs more per millisecond but runs in a third of the time can be cheaper overall. Do not leave memory at the default without checking.
The timeout. Every function has a maximum run time you configure, and the ceiling is 15 minutes. Hit it and Lambda kills the invocation mid-flight. This is a hard wall, and it is the first thing that tells you whether a workload belongs in Lambda at all. Set the timeout to a realistic value, not the max - a function that should take 2 seconds but is configured for 15 minutes will hang for 15 minutes when something goes wrong.
Concurrency. Each invocation runs in its own isolated environment. Ten simultaneous requests mean ten copies of your function running at once - Lambda does not queue them behind a single instance. That is the automatic scaling, and it has two consequences. First, there is an account concurrency limit (1,000 by default, raisable) that all your functions share. Second, downstream systems feel it: a spike that spins up 500 concurrent Lambdas will open 500 connections to your database, which is a classic way to knock over a database that was fine at "normal" load. Use reserved concurrency to cap a function, or provisioned concurrency to keep warm instances ready.
Triggers and event sources
A Lambda function does nothing until something invokes it. That "something" is an event source, and the same function code can be wired to very different triggers.
- API Gateway - turns HTTP(S) requests into Lambda invocations. This is how you build a serverless REST or HTTP API: a route like
POST /ordersinvokes your function with the request as the event. (Function URLs are a lighter-weight alternative for a single endpoint.) - S3 events - a Lambda runs when an object is created or deleted in a bucket. The canonical use: someone uploads an image, an S3 event triggers a function that generates a thumbnail.
- EventBridge (including scheduled/cron) - event bus and scheduler. Use it to run a function on a schedule (
rate(1 hour)or a cron expression) for cleanup jobs and reports, or to react to events from other AWS services. - SQS - a queue triggers Lambda to process messages in batches, which decouples producers from workers and smooths out spikes. Lambda pulls messages and scales with the queue depth.
- Plus many more: DynamoDB Streams and Kinesis for change/stream processing, SNS for pub/sub fan-out, and direct SDK/CLI invocation.
The event shape changes per source, so your handler must read the right fields. An S3-triggered function, for example, gets the bucket and key, not an HTTP body:
export const handler = async (event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key);
console.log(`New object: s3://${bucket}/${key}`);
// fetch the object, process it, write a result...
}
};
You connect the trigger in the function's configuration (or your IaC), not in the code. The code just reads whatever event it is handed.
Cold starts
Because Lambda scales to zero, the first request to a function that has no warm instance pays a penalty: the platform has to create an execution environment, download your code, and initialize the runtime before your handler even runs. That startup delay is a cold start. Once an environment is warm it is reused for later invocations (a warm start, with no penalty) until it is torn down after a period of idleness or when scaling up needs a fresh copy.
Cold starts usually add tens to a few hundred milliseconds, more for heavy runtimes or large deployment packages. Ways to reduce them:
- Keep the package small. Less code and fewer dependencies means faster download and init. Trim what you bundle.
- Do heavy setup once, outside the handler. Code at module scope (creating clients, loading config) runs during init and is reused across warm invocations. Put connection setup there, not inside
handler. - Choose a lighter runtime where it matters. Interpreted/compiled-fast runtimes (Node.js, Python, Go) cold-start faster than heavy JVM-style startups.
- Provisioned concurrency for latency-critical paths - it keeps N environments initialized and warm, so those requests never see a cold start (you pay to keep them ready).
import { S3Client } from "@aws-sdk/client-s3";
// Runs ONCE per environment, during init - reused on warm invocations.
const s3 = new S3Client({});
export const handler = async (event) => {
// Reuse the already-created client instead of building one per call.
// ... use s3 ...
};
For most background and event-driven work, cold starts are a non-issue. They matter most on user-facing, latency-sensitive HTTP paths - measure before you spend money fixing them.
The IAM execution role and least privilege
A Lambda function assumes an IAM execution role - this is the identity the function runs as, and it defines what AWS resources the code is allowed to touch. There is no access key baked into your code; the role's temporary credentials are injected into the environment automatically, and the AWS SDK picks them up. This is exactly how you want it, and it is why you should never hardcode credentials in a function.
The rule is least privilege: grant the role only the specific actions on the specific resources the function actually needs, and nothing more. A thumbnail function that reads from one bucket and writes to another needs s3:GetObject on the source and s3:PutObject on the destination - not s3:* on every bucket in the account. Every function should also have permission to write its own logs (that is what the managed AWSLambdaBasicExecutionRole policy grants). If you are new to how roles, policies, and permissions fit together, start with the IAM guide - the execution role is one specific application of everything there.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::uploads-bucket/*"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::thumbnails-bucket/*"
}
]
}
Scoping the role tightly is not busywork - it is what stops a bug or a compromise in one small function from turning into account-wide blast radius.
Environment variables and secrets
Configuration goes in environment variables on the function, not baked into the code, so the same package runs in dev, staging, and prod with different settings. You read them the normal way for your runtime (process.env.TABLE_NAME in Node, os.environ in Python).
That is fine for non-sensitive config (table names, log levels, feature flags). It is not the right home for real secrets. Lambda environment variables are visible to anyone with permission to view the function's configuration, and they show up in the console. For database passwords, API keys, and tokens, use AWS Secrets Manager or SSM Parameter Store (SecureString), and have the function fetch the secret at init time using its execution role. That keeps the secret out of the function config, gives you rotation, and audits access.
const TABLE = process.env.TABLE_NAME; // fine as an env var
// const DB_PASSWORD = process.env.DB_PASSWORD; // do NOT do this
// Instead: fetch from Secrets Manager at init, using the execution role.
Packaging: zip, container image, and layers
There are two ways to ship a function:
- Zip archive - your code plus its dependencies, up to 50 MB zipped (250 MB unzipped). This is the default and the simplest path for most functions.
- Container image - package the function as an OCI/Docker image, up to 10 GB. Reach for this when your dependencies are large or awkward (native binaries, big ML libraries), or when you already build everything as containers and want one consistent workflow.
Layers are a third piece that works with zip functions: a layer is a separate archive of shared dependencies or common code that multiple functions can pull in, so you do not re-bundle the same libraries into every function. Good for shared SDK versions or a common utility set. Note that layers add to your package size and can affect cold start, so do not over-use them.
Pick zip until you have a concrete reason not to; move to a container image when size or your existing build pipeline pushes you there.
The limits, and when Lambda is the wrong tool
Lambda is excellent for a specific shape of work: short, event-driven, stateless, bursty tasks. It is a poor fit for the opposite, and knowing the boundary is what separates good architecture from a painful one. The hard limits that define the boundary:
- 15-minute max execution time. Anything longer simply cannot run as a single Lambda.
- Stateless by design. Each invocation is isolated and the environment can vanish between calls. You cannot rely on local state, in-memory caches, or files on disk persisting across invocations. State lives in a database, S3, or a cache - outside the function.
- Concurrency and downstream pressure. Automatic scaling is a feature until it stampedes a database or an external API. You often have to cap concurrency or put a queue in front.
- Cold starts on latency-critical, spiky HTTP paths.
So Lambda is the wrong tool for:
- Long-running work - a job that runs for an hour, a video encode, a large batch. It cannot fit in 15 minutes. Use containers (ECS/Fargate), AWS Batch, or EC2.
- Heavy, sustained, or high-throughput compute - something pinned at high CPU all day long is usually cheaper and simpler on a right-sized instance than on Lambda, where you pay per-invocation and fight cold starts and concurrency limits.
- Stateful workloads - anything that needs a durable local process, in-memory session state, or persistent connections held open (a game server, a stateful websocket hub). That wants a real server or a container.
- Predictable, steady, always-on load - if something runs constantly at a known rate, a reserved instance or a container often costs less than millions of invocations.
The honest summary: Lambda shines when work is triggered by events, finishes quickly, and holds no state between runs. When any of those is false - long, heavy, or stateful - reach for containers or EC2 instead. Serverless is a tool, not a religion; using it where it fits is the whole skill.
The shape of it
Serverless means you never manage the servers, you pay per invocation, and it scales to zero. Lambda is the AWS service that runs a function in response to an event: a handler that receives the event and a context, sized by memory (which drags CPU with it), capped by a 15-minute timeout, and scaled by running many copies concurrently. Triggers - API Gateway for HTTP, S3 events, EventBridge cron, SQS, and more - decide when it runs. Cold starts are the price of scaling to zero; keep packages small, initialize outside the handler, and use provisioned concurrency where latency matters. The function runs as a least-privilege IAM execution role (see the IAM guide), reads config from environment variables and real secrets from Secrets Manager, and ships as a zip or a container image. And the most valuable thing to know is where it stops: long, heavy, or stateful work belongs on containers or EC2, not Lambda.