Someone else runs the servers, you pay per request, and it scales to zero. Excellent for some workloads and quietly expensive for others.
Prerequisites: Scalability, Monolith vs Microservices Time to read: ~22 minutes
You have an image-thumbnailing service. Traffic is spiky — a few requests most of the day, thousands when a customer bulk-uploads.
Provisioned servers: you size for peak (expensive, mostly idle) or for average (fails at peak). Autoscaling helps but takes minutes to react and never scales below one instance.
📐 If the service is genuinely used for 30 minutes a day, you’re paying for 24 hours of capacity to serve 30 minutes of work — roughly 48× more than the work requires.
Serverless: no instances when there’s no traffic, thousands of concurrent executions within seconds, and you pay per 100 ms of execution.
Not “no servers” — no servers you manage, and no capacity you provision.
| Property | Meaning |
|---|---|
| No server management | No patching, no capacity planning, no OS |
| Automatic scaling | Zero to thousands of concurrent executions, per-request |
| Scales to zero | No traffic, no instances, no cost |
| Pay per use | Billed per invocation and per GB-second, not per hour |
| Event-driven | Triggered by HTTP, a queue message, a file upload, a schedule |
| Stateless by design | Each invocation is independent; nothing persists in the process |
The categories:
1. Spiky or unpredictable traffic. The canonical case. Idle costs nothing; a 100× spike is absorbed automatically.
2. Event processing. A file lands in S3 → resize it. A message arrives → process it. A row changes → update the search index. This is what FaaS is actually best at, and it’s a much better fit than HTTP APIs.
3. Scheduled jobs. A cron job that runs for 30 seconds a day, on an always-on instance, is absurd. → Background Jobs
4. Glue code. Small integrations between services. Webhook receivers. Format transformations.
5. Genuinely low volume. An internal tool used ten times a day costs cents rather than the price of an instance.
6. Small teams without operational capacity. No Kubernetes to run, no patching, no capacity planning. This is often the real reason, and it’s a legitimate one.
📐 The cost crossover, roughly:
Lambda: ~$0.20 per million requests + ~$0.0000167 per GB-second
100k requests/month, 200 ms, 512 MB: ~$0.20/month
1M requests/month, 200 ms, 512 MB: ~$1.90/month
100M requests/month, 200 ms, 512 MB: ~$190/month
1B requests/month, 200 ms, 512 MB: ~$1,900/month
An equivalent always-on fleet handling 1B/month: perhaps $300–800/month.
🚨 The crossover is somewhere around sustained high volume. Below it, serverless is dramatically cheaper. Above it, provisioned capacity wins — sometimes by a lot.
🚨 The most-discussed limitation, and the most misunderstood.
When no warm instance exists, the platform must provision one: download your code, start the runtime, initialize your application, then handle the request.
| Runtime | Typical cold start |
|---|---|
| Cloudflare Workers (V8 isolates) | < 5 ms |
| Go, Rust | 100–300 ms |
| Node.js, Python | 200–600 ms |
| Java, .NET | 1–10 seconds |
| Any of the above in a VPC (older AWS) | Historically +10 s; largely fixed since 2019 |
Mitigations:
⚖️ The honest assessment: cold starts matter for user-facing synchronous APIs with tight latency budgets, and matter very little for asynchronous event processing, where an extra 300 ms is irrelevant. This is a good discriminator for whether serverless fits.
Cold starts get the attention; these cause more actual problems.
Execution time limits. Lambda caps at 15 minutes. Long jobs must be decomposed, or moved to Fargate/Batch. This kills a lot of otherwise-good candidates.
🚨 Statelessness is absolute. No in-memory cache between invocations that you can rely on, no local files that persist, no WebSocket connections held open. Everything goes to external state, which adds latency and cost.
🚨 The database connection problem — the most common serverless failure.
1,000 concurrent Lambda invocations
× 1 database connection each
= 1,000 connections to a Postgres instance that handles ~200 well
→ the database falls over
Traditional connection pooling doesn’t work, because there’s no long-lived process to pool in. Solutions: a proxy (RDS Proxy, PgBouncer), a serverless-native database (DynamoDB, Aurora Serverless v2 with Data API), or a database designed for HTTP-style access (Neon, PlanetScale). → Connection Pooling
Local development and testing are genuinely worse. Emulators (LocalStack, SAM) approximate the cloud imperfectly; many teams end up deploying to a personal cloud environment to test.
Debugging and observability are harder. No SSH, no process to attach a profiler to, and distributed tracing becomes mandatory. → Distributed Tracing
Vendor lock-in is real — not so much the function code as the surrounding ecosystem of triggers, IAM, and managed services it’s wired into.
Cost unpredictability. Per-request billing means a bug, a retry storm, or an attack translates directly into a bill. 🚨 Set budget alarms and concurrency limits from day one.
Concurrency limits. Accounts have limits (Lambda defaults to 1,000 concurrent executions per region). One runaway function can starve every other function in the account, which is a genuinely surprising blast radius.
🚨 The comparison people skip, and often the more relevant one.
| FaaS (Lambda) | Serverless containers (Cloud Run, Fargate) | Managed containers (EKS/ECS) | |
|---|---|---|---|
| Unit | A function | A container | A container |
| Scales to zero | ✅ | ✅ (Cloud Run) | ❌ |
| Cold start | 200 ms – 10 s | 1–5 s | None (always warm) |
| Max duration | 15 min | Hours | Unlimited |
| Concurrency per instance | 1 | Many (Cloud Run: up to 1,000) | Many |
| Local dev | Poor | ✅ Just a container | ✅ |
| Lock-in | High | Low — it’s a container | Low |
| Long-lived connections | ❌ | ✅ | ✅ |
🎙️ A strong, non-obvious answer: “I’d use Cloud Run rather than Lambda here. We keep scale-to-zero and per-request billing, but it’s an ordinary container — so local development is normal, there’s no 15-minute limit, one instance handles many concurrent requests so connection pooling works, and we’re not locked in.”
The multi-concurrency point matters more than it appears: Lambda handles one request per instance, so 100 concurrent requests means 100 instances and 100 database connections. Cloud Run’s single instance handling 80 concurrent requests means one connection pool — which quietly solves the database problem.
Serverless pushes you toward a particular shape, and it’s worth naming.
Event-driven by default. Functions are triggered, so systems become chains of events. → Event-Driven Architecture
Managed services for everything stateful. No connection pools means DynamoDB rather than Postgres; no in-process cache means ElastiCache or DynamoDB; no local files means S3.
🚨 The “Lambda pinball” anti-pattern: decomposing a workflow into fifteen tiny functions, each invoking the next through a queue. You get enormous latency (each hop is a cold-start risk), a distributed system that’s impossible to trace, and per-invocation costs multiplied by fifteen. Prefer fewer, coarser functions — a “Lambdalith” (one function serving a whole API via an internal router) is a legitimate and increasingly recommended pattern.
Step Functions / workflow engines for anything multi-step, rather than chaining functions manually. → Saga Pattern
🎙️ “Traffic here is steady at a few thousand requests per second, so scale-to-zero buys us nothing and per-request billing would cost several times a provisioned fleet. I’d use containers. I would use Lambda for the image processing pipeline, which is spiky and event-driven.”
Mixing is normal and correct. Most real systems use serverless for some workloads and provisioned capacity for others.
| Gain | Cost | |
|---|---|---|
| Serverless | No ops, automatic scaling, pay per use, scales to zero | Cold starts, time limits, statelessness, lock-in |
| Provisioned capacity | Predictable cost and latency, no limits, warm connections | Capacity planning, patching, paying for idle |
| Provisioned concurrency | No cold starts | You’re paying for idle again |
| Serverless containers | Scale-to-zero without lock-in; normal local dev | Slightly slower cold starts than V8 isolates |
| Managed database (DynamoDB) | No connection problem, scales with the functions | Access-pattern-first modelling; different cost model |
1. Measure cold starts yourself. Deploy the same trivial function in Python, Go, and Java. Invoke each after 15 minutes of idleness and measure. Then invoke repeatedly and compare warm latency. The runtime difference is much larger than people expect — and it tells you which languages suit latency-sensitive serverless.
2. Break a database with concurrency. Point a Lambda at a small Postgres instance. Load-test at 200 concurrent invocations. Watch connections exhaust and the database refuse connections. Then add RDS Proxy (or switch to Cloud Run with multi-concurrency) and re-run.
3. Do the cost arithmetic honestly. For a workload you know — requests/month, average duration, memory — price Lambda against an equivalently-sized EC2/Fargate deployment. Find the crossover point. Then re-run it assuming a bug causes 10× the invocations, and see what the bill does.
4. Compare FaaS and serverless containers. Deploy the same API as a Lambda and as a Cloud Run service. Compare cold start, local development experience, and the code you had to write. The difference in developer ergonomics is usually the deciding factor in practice.