Serverless is rarely the wrong choice on the day it is made. It becomes wrong later, gradually, through a series of reasonable local fixes that nobody adds up. This chapter is about the inflection points on cost, latency, concurrency and lock-in, the mechanisms behind each one, and the tripwires that tell you early enough that changing course is still a quarter of work rather than a year.
Strip the marketing away and a function platform sells three things: zero idle cost, no capacity planning, and no ownership of undifferentiated runtime plumbing. You pay for them with a per-unit price premium, an execution model that allows one in-flight request per sandbox with a bounded lifetime, and an architecture whose behaviour is increasingly defined by the provider's managed services rather than by your code. All three purchases are worth most when you are small, traffic is spiky, and the team has no platform. All three depreciate as the product succeeds.
That is the core of the judgement: serverless is not a destination you pick, it is a position that decays as duty cycle rises, latency requirements tighten, and the event graph grows. Chapter 1 (How Architects Reason About Trade-offs) treated reversibility as a property of a decision; here it is a property that changes over time. The migration from an all-FaaS system is cheap while the glue is thin and expensive once billing, onboarding and entitlements are expressed as state machines and event rules.
The composite below is a common shape for B2B SaaS companies that went all-in on functions with a team of about six. Each line on it is crossed quietly, and each crossing is patched with a feature the platform sells precisely for that purpose.
Illustrative composite, not one company. No single event is dramatic; each is patched locally (RDS Proxy, provisioned concurrency, reserved concurrency). The patches are the signal: each one buys back a piece of the server model while keeping the serverless price and constraints. By the time the lines are all pink, the same migration has to unpick state machines, event rules and a data model, not just re-host handlers, which is why it costs a multiple of what it would have in the gold window.
Read the patches carefully. RDS Proxy is a connection pool you would have had for free in a long-running process. Provisioned concurrency is pre-allocated capacity, charged whether used or not. Reserved concurrency used to protect a database is a hand-tuned bulkhead. Each is the correct local fix. Taken together, they reconstruct the server model piece by piece, at serverless unit prices and with serverless constraints still attached. That pattern, not any single incident, is the first signal.
Architect's note
The usual back-of-envelope compares function GB-seconds with instance-hours and concludes serverless wins below some utilisation. That analysis is directionally right and quantitatively misleading, for three reasons.
First, the unit price gap is larger than people assume. At x86 list price Lambda charges about $0.0000167 per GB-second, which is roughly $0.06 per GB-hour. A general-purpose on-demand instance (m7i-class, 8 GB) lands around $0.012 per GB-hour. That is a ~5x premium per unit of memory-time before any discount on the instance side, so a provisioned fleet has to sit below roughly 20% utilisation for functions to win on compute alone.
Second, and less appreciated: the sandbox bills for wall-clock time with one request in flight. A 100 ms handler that spends 85 ms waiting on Postgres and a downstream API is billed for 100 ms of a full sandbox. A Go or Node process serves dozens of those I/O-bound requests concurrently. Work it through: 15 ms of CPU per request means one vCPU tops out around 65 such requests a second, while a 1 GB sandbox serves 10. The unit premium compounds with that multiplexing factor, which for CRUD-over-network work is routinely an order of magnitude. This is why Cloud Run's per-instance request concurrency (default 80) changes the economics so much for the same kind of workload: it removes the multiplier, not the premium.
Third, the function is often not the biggest line. API Gateway REST APIs list at $3.50 per million requests, which is about twice the compute cost of a 1 GB, 100 ms function. CloudWatch Logs ingestion is $0.50 per GB; one chatty kilobyte per invocation at a few hundred rps is a real bill. Step Functions Standard workflows charge $25 per million state transitions, so a workflow that takes a few transitions per item is effectively a per-item tax on your throughput. The Prime Video team's widely read 2023 post on moving an audio/video monitoring service from a Step Functions and Lambda design to a single process on ECS reported over 90% lower infrastructure cost, and the drivers they named were exactly this kind of glue: per-transition orchestration cost and passing intermediate data through S3 between steps.
~5x
unit premium, GB-hour, list
~10x
I/O multiplexing a server gets
$3.50/M
API GW REST, first tier
$25/M
Step Functions std transitions
Assumptions (illustrative): 1 GB function, 100 ms mean duration, x86 list prices, no free tier; ~1 KB of logs per request; containers = 1 vCPU / 2 GB tasks at ~60 rps each (15 ms CPU per request), minimum three across AZs, plus a load balancer; "owned platform" adds a flat ~0.25 FTE. Pure infrastructure breaks even at ~10–30 rps. The crossover that matters is A or B, and which one applies is an organisational fact, not a technical one.
The chart makes the uncomfortable point. On pure infrastructure, a minimum three-task container fleet beats API-fronted functions at around 10 rps sustained, and compute-only functions at under 30. Almost nobody should migrate at 30 rps, though, because the container line does not include the people who operate it. If you already have a platform team, a paved road, and on-call for services, the marginal cost of one more service is near zero and the crossover is effectively where the solid green line crosses the Lambda lines: tens of rps, far to the left. If moving off functions means building and owning that platform, the crossover moves right to A or B, and for a small team it may never arrive before other priorities do.
Work the composite from 4.1 through at year 3, using the chart's illustrative model. The core API runs 400 rps sustained. Functions behind a REST gateway with ~1 KB of logs per request cost about 15.2 × 400 ≈ $6,100 a month, of which compute is roughly $1,940 and the gateway plus logs the other ~$4,150: a glue-to-compute ratio just over 2, which is a red tripwire on its own. Add provisioned concurrency to fix the enterprise p99 clause: at list price a 1 GB unit of provisioned concurrency is about $10.80 a month before any invocation charge, so five warm units on each of 30 functions is another ~$1,600 of capacity bought whether or not traffic arrives. The same path on seven container tasks and a load balancer is under $300 of infrastructure. The ~$7,400 monthly gap is ~$89k a year, roughly half a loaded engineer. That is not a number that justifies building a platform from scratch at a six-person company; it is decisively a number that justifies moving one path onto a platform the company already has, and at 3x annual traffic growth it doubles in about eight months.
Two cost behaviours never show up in list-price models and routinely dominate real bills. Retries multiply spend on the failure path: an async function that fails is invoked up to three times, an SQS mapping redelivers until maxReceiveCount, and a Step Functions retry policy is another multiplier on top, so a poison message at the head of a busy queue costs its full duration several times over while also doing no useful work. And observability scales per invocation, not per host: tracing, log ingestion, and third-party monitoring agents priced per function or per million spans grow with request count, whereas a container fleet emits roughly per-process telemetry plus sampled traces. It is common for the monitoring vendor's serverless add-on to be the second-largest line after the gateway, and nobody put it in the original comparison.
Pitfall · the savings-plan illusion
What gets missed about cold starts is how they aggregate. AWS's own performance guidance puts cold starts at typically under 1% of invocations, and that is roughly what a per-function dashboard shows. The user experiences the union across every function touched by their request. If a page load or API call touches n functions synchronously, whether sequentially or in parallel fan-out, the probability that it pays at least one init is 1 − (1 − p)^n.
The per-function metric understates user-visible impact. With 20 synchronous touches per request, a "1% cold-start rate" means roughly one request in five pays at least one init penalty, which puts cold starts squarely inside your p90, not out in the p99.9. And the rate is not stationary: it spikes during scale-out, which is exactly when you are being judged.
A design with function-per-endpoint and function-per-backend-concern easily reaches 10–20 synchronous touches for a rich page, especially after a BFF layer fans out. At that point init latency is no longer tail noise; it defines your p90. Worse, p is not stationary. Cold starts are generated by scale-out, and scale-out happens under load spikes, deployments (every new version starts cold), and after quiet periods. The cold-start rate is therefore positively correlated with the moments that matter: the launch, the Monday 9 a.m. ramp, the recovery after an incident.
Two refinements matter when you use the formula in a design review. It assumes independence, and cold starts are not independent: they cluster in time. That lowers the long-run average but concentrates the damage, so during a scale-out burst nearly every request on a wide path pays, which is what your largest customer notices. And topology decides the penalty. Parallel fan-out pays the max of the init times; a sequential chain pays their sum, and a three-hop chain behind a burst can stack three inits of several hundred milliseconds each for a JVM runtime without SnapStart. Cold starts also stopped being free: since August 2025 AWS bills the init phase for managed runtimes too, so a high churn rate now shows up on the bill as well as in the latency histogram.
The second latency cost is subtler: per-sandbox state is small and short-lived. Every sandbox has its own TLS sessions, its own DNS cache, its own connection pool of size one, its own cold in-process cache, its own JIT warm-up. A long-running service amortises all of that across millions of requests; a sandbox amortises it across however many requests it serves before being recycled. For JVM and .NET workloads that relied on JIT warm-up and large in-memory caches, this is not a constant penalty, it is a permanently lukewarm runtime. SnapStart-style snapshotting helps with init, not with the loss of hot caches.
The standard fix, provisioned concurrency, deserves an honest name: it is capacity planning with a per-hour charge. You choose a number, you pay for it whether traffic arrives or not, and you get paged when you chose wrong. When provisioned concurrency covers most of a critical path, you have kept every constraint of the function model and given back its main economic advantage.
Where the line is
Little's law (concurrency = arrival rate × duration) is the whole story, read in the direction people do not usually read it. Behind a fixed pool, concurrency is bounded by the pool and excess arrivals queue or get rejected. On a function platform it is bounded only by the account and function limits, which are deliberately high (the regional default quota starts at 1,000, and each function can add up to 1,000 concurrent executions every ten seconds under the scaling model AWS introduced in late 2023). The platform is doing its job. The problem is what sits behind it.
Hold arrival rate constant and let the database slow down, from a checkpoint, an autovacuum on a hot table, a bad plan after statistics drift. Duration rises, so required concurrency rises, so more sandboxes start, each opening its own connection, which slows the database further. That is positive feedback. A fixed pool has the opposite property: the pool is a bulkhead, so overload turns into queueing and shedding at the edge while the database sees roughly constant load and recovers.
Per-request sandboxes · concurrency = rps × duration
phase
DB p50
fn dur.
sandboxes
DB conns
steady state
20 ms
60 ms
30
30
checkpoint / vacuum stall
200 ms
240 ms
120
120
connection pressure
600 ms
640 ms
320
320 → max_conn
timeouts + retries
timeouts
~1 s+
1,000 cap
refused · storm
Positive feedback: slower DB → longer durations → more sandboxes → more connections → slower DB. The platform scales into the bottleneck.
Fixed pool · 20 connections per fleet
steady state
~10 of 20 conns busy · queue ≈ 0
checkpoint / vacuum stall
20 conns busy · in-process queue grows
connection pressure
still 20 conns · queue wait > 250 ms → 503 at edge
recovery
DB sees constant load, drains, recovers on its own
Negative feedback: the pool is a bulkhead. Overload becomes queueing and shedding at the edge, never more pressure on the thing that is already slow.
Illustrative numbers; one connection per sandbox, as is typical without a proxy. In the last row 500 rps × ~1 s already needs 500 sandboxes, and retried async events push demand past the 1,000 regional default: now every function in the account throttles, not just this one. RDS Proxy or PgBouncer caps the connection count, but the sandboxes still pile up waiting on the proxy, burning GB-seconds and account concurrency while they wait.
Two amplifiers make this worse in practice. Asynchronous invocations retry (twice by default), and event-source integrations redeliver on failure, so an error spike becomes an arrival-rate spike with the same payloads. And concurrency is a shared regional pool: the backfill someone triggered through an SQS-driven function at 2 p.m. consumes concurrency the customer-facing API needs, and throttles it. Reserved concurrency solves the second problem by partitioning the pool, which is to say by capacity planning per function. Maximum concurrency on the event source mapping and a proxy in front of the database solve the first. All of these work. Count them: if the architecture needs three or four of them to stay upright, the request path wants a process with a pool.
The incident that usually forces the conversation looks like the composite below. What makes it expensive is not the initial stall, which a well-run Postgres absorbs a few times a quarter, but that the architecture has no point where excess demand is refused before it reaches the database. Every layer admits more work as the database slows, and the recovery gets hit by the backlog accumulated during the outage.
time
database
function platform
users & other teams
T+0
autovacuum on a hot table; p50 20 → 150 ms
durations rise; sandboxes 30 → 90
nothing yet
T+2
connections 90 → 260; lock waits appear
scale-out adds sandboxes, each opens a connection
API p99 crosses 1 s
T+4
max_connections hit; new connects refused
init failures count as errors; async retries queue
5xx on writes; sync callers retry too
T+6
CPU saturated on connection churn and auth
SQS mapping scales up on backlog; account pool near 1,000
unrelated fns throttled (429): webhooks, auth hooks
T+12
vacuum finishes; DB could recover
retry wave + backlog drain arrive at full concurrency
second brownout, worse than the first
T+30
stable once someone sets reserved concurrency by hand
DLQs hold a few thousand exhausted events
support tickets; partial state in two services
T+2 d
—
DLQ redrive replays stale events on current state
duplicate emails, double-applied credits where not idempotent
The shape to notice is the second peak at T+12: the recovery is attacked by the platform's own backlog, because retries and queue drain are admitted at the same unbounded concurrency that caused the first stall. A service with a fixed pool would have shed load at T+4 and been back at T+13. The two-day tail comes from redrive, which is where missing idempotency is finally discovered.
Three design-review consequences follow. First, every function that touches a shared stateful dependency needs a concurrency ceiling derived from that dependency: if the database comfortably serves 200 connections and four function groups share it, their reserved or maximum concurrency must sum to under 200, with headroom for migrations and admin sessions. That is capacity planning, done per function, and it has to be revisited every time someone adds a function. Second, retries must be budgeted, not defaulted: async retry count, maximum event age, SQS maxReceiveCount and Step Functions retry policies each multiply load on the failure path, and the product of their defaults is rarely what anyone intended. Third, redrive is a production deployment. A DLQ replayed two days later executes old intents against new state; unless handlers are idempotent on a business key and check preconditions rather than blindly applying, the redrive is a second incident.
Pitfall · RDS Proxy moves the queue, it does not remove it
The execution limits (15-minute duration, 6 MB synchronous payload, no durable in-process state) push long work into chunked, checkpointed state machines. Sometimes that is the right design anyway. When it is adopted only to fit the platform, the orchestration becomes a second program whose state lives in a vendor's workflow engine, which is the bridge to lock-in.
"It's just a handler, we can move it anywhere" is true and irrelevant. Handlers are the most portable part of a serverless system. What is not portable is the set of behaviours the system depends on that are defined by managed-service semantics: how a queue integration batches and reports partial failures, what an event rule matches, how a state machine retries and times out, which IAM role can invoke what, what order a table stream emits changes in, and what the DLQ contains after redrive.
Handler code
thin adapter over a portable domain library
Packaging & runtime
zip → container image; entrypoint and init model
Triggers & event-source mappings
batching, partial-batch failure, retry + DLQ semantics
IAM & resource policies
per-function roles, cross-service grants, KMS
Orchestration & routing
Step Functions state machines, EventBridge patterns
Data model
single-table design tuned to access patterns; streams as integration
Ops surface
alarms, tracing, deploy tooling, runbooks, on-call muscle memory
The function body, which is what people point at when they say "it's just code, we can move it", is the smallest slice. Lock-in accumulates in the glue: the behaviours your system relies on that are defined by a managed service's semantics rather than by your code. Count integration edges, not functions.
Concretely, these are the semantics a migration has to reproduce, usually discovered one incident at a time. An SQS-triggered function with ReportBatchItemFailures relies on per-message redelivery inside a batch, governed by a visibility timeout that AWS recommends setting to at least six times the function timeout; a hand-rolled consumer has to reproduce both, or partial failures turn into duplicate processing of whole batches. DynamoDB Streams order changes per item and keep records for 24 hours, so every consumer silently depends on catching up within a day, and on never needing cross-item ordering. EventBridge delivers at least once with no ordering guarantee, which means the idempotency your consumers need is already in the code if you were careful and missing if you were not; the migration is when you find out which. Step Functions Standard retries, catch clauses and per-state timeouts are declarative, and teams frequently lean on them without the handler code knowing it is being retried. None of this is exotic. All of it is behaviour that exists nowhere in your repository except as configuration.
The migration effort is dominated by the lower layers. An orchestration defined as a state machine has to be re-implemented as code, a workflow engine such as Temporal, or another state machine, and its semantics must be matched exactly, including the retry and timeout edge cases that nobody documented because the platform handled them. A data model that was designed around a key-value store's access patterns, with its change stream acting as the integration bus, cannot be lifted onto another store without redesigning both. Chapter 3 (Kafka, SQS, RabbitMQ or Pub/Sub?) covers how delivery semantics differ between brokers; in a heavily serverless system those semantics are baked into dozens of integrations at once.
There is also an organisational form of lock-in. A system with 200 functions and 80 event rules has an event graph that exists only in infrastructure code and in the heads of the two engineers who built it. Local development needs a deployed stack per developer. Integration tests run against the cloud. New hires take months to predict what a change will trigger. This is the nano-service failure from Chapter 2 (When Microservices Are the Wrong Choice) at finer grain: the coupling is still there, it just moved from call graphs into event rules where it is harder to see.
Architect's note
None of this means avoid lock-in. Buying managed semantics is often the best trade a small team can make; it is lock-in you are paid for in engineering time. The judgement is whether you are still being paid. A state machine that saved two engineer-months in year one and now blocks every billing change in year three has stopped paying.
Two variables do most of the work. Duty cycle: how much of the time the work is actually running, for which the trailing minimum concurrency is a good proxy. Path criticality: whether a human or a synchronous caller is waiting, with a latency SLO attached, or whether the work is asynchronous glue whose latency budget is seconds or minutes. Classify each request path, not each service, and certainly not the company.
async ↑ · sync ↓
Spiky / low duty cycle · async glue
Serverless, without apology
webhooks, file-drop processing, nightly exports, ops automation, fan-out notifications
Steady / high duty cycle · async glue
Serverless until the bill says otherwise
stream consumers, ETL steps. Watch glue cost and batch size; containers on a queue win above sustained volume
Spiky / low duty cycle · sync hot path
Scale-to-zero containers, or FaaS + pre-warming
B2B APIs with office-hours traffic. Prefer a runtime with per-instance request concurrency
Steady / high duty cycle · sync hot path
Long-running services
core API, auth, pricing, search. Pooled connections, in-process caches, predictable p99
low duty cycle
high duty cycle →
Team < ~8, no platform
shifts everything toward serverless; the owned-platform cost is real and immediate
Platform team exists
shifts toward containers; marginal cost of one more service is small
Regulated / residency
check runtime and region availability, audit of managed glue, and key custody before either
The duty-cycle axis can be made quantitative. Take the ratio of mean to peak concurrency over a representative week. An autoscaled fleet with ~30% headroom and a floor of three instances runs at roughly that ratio divided by 1.3, slightly worse at the floor. From 4.2, functions win on compute only when the fleet would sit below about 20% utilisation, and the multiplexing factor divides that threshold again for I/O-bound work. So for a synchronous, I/O-bound path, functions are only cheaper when mean concurrency is below a few percent of peak: nightly jobs, webhooks, internal tools. A consumer API with an ordinary diurnal curve often runs mean-to-peak somewhere around a third to a half (illustrative), an order of magnitude past that line. The exception is paths whose peak concurrency is tiny in absolute terms; below one busy instance, the fleet floor dominates and the ratio is irrelevant.
Two quadrants are easy. Low-duty-cycle glue belongs on functions permanently; teams that sweep it into containers as part of a broader move add ops burden for nothing. The steady synchronous hot path belongs on long-running services, which win on every axis at once. Judgement lives on the diagonal: high-volume async glue stays on functions until the glue bill or downstream concurrency says otherwise, and spiky synchronous APIs usually belong on scale-to-zero containers with request concurrency, which keep most of the economics without the one-request-per-sandbox multiplier.
In a review, the matrix is easier to apply as an ordered set of questions, because it stops people jumping straight to the platform argument. Ask the cheap questions first; each path should leave the tree at the earliest question it can.
Order matters: the cheap exits come first. Most paths leave at the first two questions and never need a cost model. The third question is a structural fix that is reversible in a sprint; only the last two involve a platform decision.
Run the tree on a real inventory and the distribution is usually lopsided. In the composite from 4.1, 180 functions collapsed to about 25 request paths. Roughly two thirds left at the first two questions and stayed exactly where they were. Four synchronous paths failed the third question; consolidating them into two Lambdaliths cut their p99 by more than the enterprise clause required and removed the need for provisioned concurrency on 22 functions. Two paths, the core read API and pricing, reached the last question. That is the typical outcome: a short list of paths to move, not a platform migration, and a larger list of paths that should be explicitly left alone so the move does not grow into an identity project.
Context moves the lines in predictable directions. Stage and team size: a pre-product-market-fit company with six engineers should bias hard toward functions, because the dominant risk is not a 3x cloud bill but spending a quarter building a platform for a product that pivots. An existing platform team biases the other way; the marginal service is cheap. Regulatory constraints cut both ways: managed runtimes remove patching and some host-level controls from your audit scope, but data residency, customer-managed keys and region availability of every piece of glue have to be checked individually, and a single unsupported managed service in a required region can force the design. Latency-sensitive enterprise contracts can flip a path overnight, as the timeline in 4.1 shows; a p99 clause in a large deal is a change in architecture requirements and should be treated as one.
Architect's note · the Lambdalith is a seam, not a smell
The cheap window in 4.1 exists only if you can see it. That requires measuring the right things before anyone is arguing about migration, because once the argument starts every metric gets cherry-picked. Define tripwires now, with thresholds agreed in advance. They are fitness functions in the sense of Chapter 1: automated, boring, and difficult to argue with.
Signal
Amber
Red
What it means
Minimum concurrency, 7-day trailing
never drops below ~20% of peak
never drops below ~50% of peak
you are running a server, billed per request
Glue $ / compute $
> 1
> 2
API GW, logs, state transitions dominate; FaaS is not the cost
Functions with provisioned concurrency
any on a critical path
> 20% of invoked fns
you have bought back capacity planning at a premium
Init share of p99 on user paths
> 20%
> 40%
cold starts are a product problem, not a tail curiosity
Reserved concurrency used to protect a downstream
1 function
a pattern
the platform is fighting your bottleneck; you need a pool
Functions per shared database
> 15
> 40
connection and schema coupling of a monolith, deployed as a swarm
Engineers who can explain the event graph
< half the team
1–2 people
lock-in has become organisational, not just technical
Each of these is cheap to compute from billing exports and platform metrics. None of them says "migrate"; two reds on the same request path say "start the design doc this quarter".
Alongside the tripwires, build seams that keep the exit cheap without paying for it up front. Keep handlers as thin adapters over a domain library that has no import from the provider SDK in its core; the adapter translates event shapes to domain calls. Ship functions as container images so the same artefact can later run on a container service with a different entrypoint; an HTTP-to-event adapter (AWS publishes one for Lambda) lets a normal web framework run in both places. Keep business rules out of state-machine definitions: let the orchestration sequence steps and handle retries, but put the decisions in code you can test locally. Define event contracts in your own schema, not as the raw provider envelope, so consumers survive a change of transport.
When a tripwire fires, the migration pattern that works is per-path strangling, not a platform swap. Pick the single hottest synchronous path that has crossed two lines. Stand it up as a long-running service using the same domain library, route a percentage of traffic to it at the gateway, compare latency, error rate and cost per request, then cut over. Leave the glue where it is. Most organisations that do this well stop after moving three to five paths and end with a deliberately hybrid system, which is the right endpoint, not a compromise. The ones that turn it into a big-bang "exit serverless" programme usually rediscover why the glue was on functions in the first place.
The timing argument is worth doing as arithmetic, because it is the one most often made on instinct. Take the composite's core API. At year 2, moving it is roughly two engineers for six weeks: handlers already call a domain library, the path touches one table and two event rules, and the company already runs a container service for its search indexer. Call it 12 engineer-weeks, about $48k at an assumed $4k loaded per week. The saving from 4.2 at that point is perhaps $4k a month, so the move pays back in about a year. At year 3.5 the same path has acquired a Step Functions billing lifecycle, a dozen EventBridge consumers that depend on the envelope it emits, and a DynamoDB stream that three teams read. Now it is three engineers for about four months, ~48 engineer-weeks or ~$190k, against savings of perhaps $9k a month: a payback approaching two years, which few roadmaps will fund ahead of product work. Six months later it is worse again. Nothing about the technology changed. The number of edges that encode business behaviour did.
Savings are converted to engineer-weeks at an assumed ~$4k loaded cost per week. Savings grow roughly linearly with traffic; exit cost grows with the number of integration edges that carry business semantics, which grows faster than traffic once workflows and event rules start encoding product behaviour. The window closes on the right, not because the move becomes impossible, but because it stops paying for itself inside any horizon a business will fund.
The window has a left edge too, and teams that learn this lesson once tend to overcorrect into it. Moving at year 0.5 on the grounds that you will need to eventually pays the full migration cost plus the platform cost for years before there is traffic to justify either, and it spends the scarcest resource an early company has, which is engineering attention on the product. The skill is not predicting the crossover; it is instrumenting well enough that you notice when you are inside the window, and having seams in place so that the right edge moves out as slowly as possible.
2 reds
same path: start the design doc
3–5
paths typically moved
1 domain lib
two adapters, one artefact
$/unit
the metric that ends the debate
Pitfall · migrating for the wrong signal
"Serverless is cheaper at low scale." True on the invoice and mostly beside the point. At low scale the infrastructure bill is a rounding error either way; what serverless actually buys a small team is not having to own patching, autoscaling, fleet deploys and capacity alarms. Justify it on that basis and you will also know when the justification expires: when a platform exists anyway, or when the people cost of the glue (debugging event graphs, per-function concurrency tuning) exceeds the people cost of a fleet.
"Design for portability so you can leave." Wrong in its usual form. Abstracting every managed service behind an interface on day one costs real engineering and still leaks semantics, because the hard part is delivery, retry and ordering behaviour, not API shape. The cheap version is the seam set in 4.7: a domain library free of provider imports, container-image packaging, business rules outside workflow definitions, and your own event envelope. That keeps the exit-cost curve flat without pretending the managed services are interchangeable.
"Provisioned concurrency fixes cold starts." It fixes the symptom on the functions you warmed, at the price of reintroducing capacity planning. It does nothing for fan-out across unwarmed functions, or for bursts past the provisioned number, which spill onto cold on-demand sandboxes exactly when load is highest; and because it is configured per version or alias, every deploy has to bring up a new warm pool before traffic shifts. If a path needs it on more than a couple of functions to meet its SLO, the path needs consolidation or a long-running process.
"Functions scale out automatically, so the compute tier cannot be the bottleneck." Scaling is the hazard, not the safety. Every stateful dependency, rate-limited third-party API and licence-bound downstream has a finite capacity, and the function tier will find it. Unbounded fan-in onto a bounded resource is the signature failure of the model, and the fix is always to reintroduce a bound somewhere.
"We went serverless, so we stay serverless", or its mirror, "serverless failed us, so we are leaving". Both treat a per-path decision as company identity. The endpoint most mature systems reach is hybrid: services on the steady synchronous core, functions for the event glue, scheduled work and spiky edges around it. The Prime Video post cited in 4.2 is often quoted as an argument against serverless. Read carefully, it describes one workload whose duty cycle and data-passing pattern had crossed the lines in this chapter, and the team said explicitly that the choice was per-service rather than a general verdict.
Questions to ask in a design review
Where next