Serverless and edge functions
Packaging and triggers, cold starts, stateless design, edge runtime limits, background work, and the pricing shapes that surprise people.
How a function runs
// a handler: one invocation, one response, no shared state to rely on
export async function handler(event, context) {
const body = JSON.parse(event.body ?? "{}");
if (!body.email) {
return { statusCode: 400, headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "email is required" }) };
}
await db.query("insert into subscribers (email) values ($1) on conflict do nothing", [body.email]);
// anything slow belongs on a queue, not in the response path
await queue.send({ type: "welcome-email", email: body.email });
return { statusCode: 202, body: "" };
}- Execution environments are reused but not guaranteed. Module-level state survives sometimes, which makes a caching bug appear intermittent.
- Every invocation needs a fresh database connection unless you use a pooler. Exhausting the database connection limit is the most common serverless outage.
- The response is the end of the work. Anything that must survive goes to a queue first.
- Timeouts are hard limits. Check the platform value before designing a request that does a slow third-party call.
| Platform limit | Typical value | Design consequence |
|---|---|---|
| Execution timeout | 10-60 s, sometimes less at the edge | Long jobs must be queued |
| Memory | 128 MB to several GB | Image and PDF work needs the larger tiers |
| Payload size | 6 MB for a synchronous request | Uploads must go directly to object storage |
| Concurrency | Account-wide, sometimes scaled by memory | One runaway function blocks everything |
| Ephemeral disk | /tmp, small and per-instance | No caching large files between calls |
| Edge runtime | No native modules, limited Node APIs | Not every library runs at the edge |
Cold starts and edge runtimes
- Measure the cold start for your actual bundle. It grows with dependency count, not with your own code size.
- Keep the handler small: import the one client you need rather than a whole SDK, and initialise expensive clients lazily but outside the handler.
- Use provisioned concurrency only where a latency budget demands it. It removes the cold start and reintroduces a fixed monthly cost.
- Edge runtimes are smaller and start faster but run a restricted JavaScript environment. Check that your dependencies and your crypto code work there before committing.
- Warm-up pings do not help reliably. Platforms retire idle instances whenever they choose.
// initialise once per instance, outside the handler
import { Pool } from "pg";
let pool;
function getPool() {
pool ??= new Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // one connection per instance
idleTimeoutMillis: 10000
});
return pool;
}
export async function handler() {
const { rows } = await getPool().query("select count(*) from orders");
return { statusCode: 200, body: JSON.stringify(rows[0]) };
}A pool per instance with max: 1, plus an external connection pooler, is the pattern that survives a traffic spike. Without the pooler, a hundred concurrent invocations open a hundred connections and the database refuses them.
Costs and background work
| Billing dimension | Why it surprises | Control |
|---|---|---|
| Invocations | A polling pattern multiplies calls | Push, or widen the poll interval |
| Compute time | Billed on wall time including waiting | Do not sit in a sleep; queue instead |
| Egress | Every byte leaving is charged | Compress, cache, and keep data in region |
| Log volume | Verbose logging is billed | Log levels per environment, short retention |
| Provisioned concurrency | A fixed hourly charge | Use only for latency-critical paths |
| NAT gateway | Charged per hour plus per GB | Avoid unnecessary outbound calls |
| Queued work | A backlog re-tried on failure | Dead-letter queues and idempotency |
Making background work safe
queue one message per unit of work
idempotency key so a retry does not double-charge a customer
dead-letter after N attempts, park the message and alert
visibility long enough that a slow job is not delivered twice
schedule from the platform, not from a self-scheduling function
Every serverless retry is a duplicate unless the handler is idempotent.⚠️
Scheduled functions and event triggers retry on failure. Never write a handler that appends a row, sends an email or charges a card without an idempotency key - the second attempt looks exactly like the first, and the customer sees the duplicate.
FAQ
Is serverless cheaper?
For spiky or low-volume traffic, usually yes. For steady high-volume traffic, a container is often cheaper because you are not paying a per-request premium. Model both at your actual request volume.
Can I use serverless for a whole application?
Yes, if the request path is short and stateless. The moment you need long-lived connections, large in-memory caches or a persistent process, containers fit better.
Related
Hosting models compared: VPS, PaaS, containers and serverless Monitoring, uptime and log management
Last refreshed 2026-09-18.