Skip to main content

Serverless, Edge, and WASM Debt

Last updated . Sources are named and dated inline - how we source claims.

Serverless removed the servers. It did not remove the debt -- it moved it into places your existing tooling cannot see, and attached a meter to it.

Cold-start workarounds that became permanent, hundreds of unowned functions, proprietary event shapes, state machines nobody can read, and edge runtime limits discovered in production. Most debt gets slower as it grows. This debt gets more expensive, every single invocation. For the VM and Kubernetes side of the same problem, see DevOps and infrastructure debt.

Why Serverless Debt Behaves Differently

Serverless debt has three properties that ordinary application debt does not, and every one of them changes how you should prioritise it.

It is dispersed. A monolith with bad code has one repository, one owner, and one place to look. A serverless estate with the same amount of bad code has it spread across hundreds of independently deployed units, several accounts, and two or three deployment frameworks that were fashionable in different years. There is no single artefact to audit.

It is invisible to your existing tooling. There is no long-lived process to attach a profiler to, no host to SSH into, and no steady-state memory graph to watch drift upward. A function that leaks, retries in a loop, or spends most of its life waiting on a slow dependency looks identical from the outside to one that does not.

It is metered. This is the property that genuinely separates serverless from everything else on this site. On a fixed fleet, inefficient code wastes headroom you already paid for; the cost of the inefficiency is bounded by the size of the fleet. In a per-invocation billing model there is no headroom. Every wasteful millisecond, every over-provisioned memory setting, every unnecessary retry is billed again on the next request and every request after that, forever, and it scales linearly with your success.

That third property is why serverless debt deserves its own treatment rather than being folded into general cloud cost debt. Elsewhere, inefficiency compounds slowly, in maintenance friction. Here it compounds financially, in a line item that grows with traffic.

Cold Starts and the Workarounds That Became Permanent

A cold start is the latency of creating a fresh execution environment: downloading the package, starting the runtime, and running your module-level initialisation code. The platform contribution is largely out of your hands. The part you own -- initialisation -- is where the debt lives, and it is usually the larger half.

The canonical mistake is doing work at module scope that only some invocations need, then paying for it on every cold start:

// The version that grew organically. Every one of these runs on every cold start,
// including for the health-check path that needs none of them.
const AWS = require('aws-sdk');                 // whole SDK, all services
const stripe = require('stripe')(process.env.STRIPE_KEY);
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();              // opens a connection at import time
const config = await loadConfigFromParameterStore();  // a network round trip
const templates = compileAllEmailTemplates();   // CPU, for a handler that rarely sends email

exports.handler = async (event) => {
    if (event.rawPath === '/health') return { statusCode: 200 };  // paid for all of it anyway
    // ...
};

The fix is not exotic. Import only what you use, defer expensive construction until first actual use, and cache it across invocations in the module scope so warm requests pay nothing:

// Import the one client you need, not the whole SDK.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

let _s3, _stripe, _config;

const s3 = () => (_s3 ??= new S3Client({}));
const stripe = async () => (_stripe ??= new (await import('stripe')).default(process.env.STRIPE_KEY));
const config = async () => (_config ??= await loadConfigFromParameterStore());

export const handler = async (event) => {
    if (event.rawPath === '/health') return { statusCode: 200 };   // now genuinely cheap
    const cfg = await config();      // built once per environment, reused while warm
    // ...
};

What turns a cold start into durable debt is the workaround. Somebody adds a scheduled ping every few minutes to keep environments warm. It works, so nobody revisits the initialisation code -- and now you are paying for invocations that do nothing, at a concurrency level somebody guessed at once, for every function that ever had a latency complaint. The warmer becomes load-bearing: remove it and latency regresses, so it can never be removed. Provisioned or always-ready concurrency is the paid version of the same trade, and it has the same property of quietly converting a fixable code problem into a permanent bill.

Treat warming as a temporary measure with a written expiry, and pair every warmer with a ticket to fix the initialisation it is compensating for. A warmer with no expiry date is not a mitigation, it is a subscription.

Function Sprawl: No Owner, No Shared Library, No Consistency

Deploying a function is easy enough that it never gets budgeted for, which is precisely why estates reach several hundred of them without anyone deciding to. The count is not the problem. The problem is that each function was written by whoever needed it, in whatever style they preferred, and there is no shared spine holding them together.

The most damaging inconsistency is error handling, because it is invisible until an incident. These three handlers all sit in the same estate:

// Function A: swallows the error. The queue message is deleted, the work is lost,
// the dashboard is green, and nobody will ever find out.
try { await process(event); } catch (e) { console.log('oops', e); }

// Function B: throws everything. A malformed message is retried forever,
// re-billed on every attempt, and eventually poisons the queue.
await process(event);

// Function C: the only correct one. Distinguishes retryable from permanent,
// and sends what it cannot handle somewhere a human will see it.
try {
    await process(event);
} catch (e) {
    if (e.retryable) throw e;                  // let the platform retry
    await sendToDeadLetterQueue(event, e);     // permanent failure, preserved
}

Nothing in a per-function deployment model pushes anybody towards C. There is no shared base class, no framework default, no reviewer who sees all three files in one diff. The fix is a small internal middleware package that every handler wraps itself in, so the correct behaviour is the path of least resistance:

// @acme/lambda-kit -- one package, adopted incrementally, no big-bang migration.
export const withStandards = (fn, { name }) => async (event, context) => {
    const log = logger.child({ fn: name, requestId: context.awsRequestId });
    const started = Date.now();
    try {
        const result = await fn(event, context, { log });
        metrics.timing('handler.ms', Date.now() - started, { fn: name, outcome: 'ok' });
        return result;
    } catch (err) {
        metrics.timing('handler.ms', Date.now() - started, { fn: name, outcome: 'error' });
        log.error({ err, event: redact(event) }, 'handler failed');
        if (err instanceof PermanentError) {          // never retry these
            await deadLetter(event, err);
            return;
        }
        throw err;                                    // retryable: let the platform decide
    }
};

export const handler = withStandards(myHandler, { name: 'orders-webhook' });

Alongside the library, enforce two pieces of metadata at deploy time: an owning team tag and a purpose. An untagged function is an unownable function, and unowned functions are exactly the ones still running two years after the feature they supported was retired -- invisible in the bill, invisible in the on-call rota, and the first thing an attacker finds when they enumerate your estate. A quarterly sweep for functions with no invocations and no owner is one of the cheapest cleanups available in this whole space.

Lock-In Through Event Shapes and Identity

Serverless lock-in is rarely about the compute. Portable code is easy. The parts that hold you are the ones nobody writes down as dependencies.

Proprietary Event Shapes

Business logic that reaches directly into a vendor-specific event envelope -- record arrays, base64 bodies, nested request contexts -- is welded to that vendor. The compute is portable; the several hundred places that destructure the envelope are not.

Identity and Policy

The deepest lock-in of all. Years of accumulated role and policy documents encode your entire authorisation model in one vendor's grammar, with no equivalent elsewhere. Nobody plans a migration around IAM, and IAM is what stops it.

Implicit Delivery Semantics

Retry counts, backoff curves, ordering guarantees, and at-least-once versus exactly-once behaviour are inherited from the trigger, never stated in code, and different on every platform. Code that quietly depends on them breaks in ways no test catches.

Deployment Framework Drift

Estates commonly carry three eras of tooling at once: a legacy YAML framework, a cloud-native infrastructure toolkit, and a general-purpose IaC tool. Each owns some functions, none owns all of them, and no single command can tell you what is deployed.

Managed Service Gravity

The function is thin because the queue, the key-value store, the scheduler, and the workflow engine are all managed. That is the correct trade, and it means the migration cost was never in the compute layer you were measuring.

Untestable Glue

Authorisers, event filters, transformation templates, and routing rules hold real logic that lives only in configuration. It is not unit tested anywhere, and it is the first thing that has no equivalent on another platform.

The Adapter Boundary That Costs Almost Nothing

You will not avoid vendor lock-in, and chasing full portability is usually a waste of a quarter. What you can do cheaply is confine the vendor to a boundary, so that the logic worth keeping is testable in isolation and the migration surface is one file per trigger type rather than every file.

// adapters/http.js -- the ONLY file that knows what an API Gateway event looks like.
export const fromHttpEvent = (event) => ({
    method:  event.requestContext.http.method,
    path:    event.rawPath,
    query:   event.queryStringParameters ?? {},
    headers: event.headers,
    body:    event.isBase64Encoded
                ? JSON.parse(Buffer.from(event.body, 'base64').toString())
                : JSON.parse(event.body ?? 'null'),
    // Identity is normalised here too. Downstream code never sees a claims blob.
    actor:   event.requestContext.authorizer?.jwt?.claims?.sub ?? null,
});

export const toHttpResponse = ({ status, body, headers = {} }) => ({
    statusCode: status,
    headers: { 'content-type': 'application/json', ...headers },
    body: JSON.stringify(body),
});

// handler.js -- vendor-free, and unit testable without any cloud emulator at all.
import { createOrder } from '../domain/orders.js';
import { fromHttpEvent, toHttpResponse } from './adapters/http.js';

export const handler = async (event) =>
    toHttpResponse(await createOrder(fromHttpEvent(event)));

The same discipline applies to your API surface generally, which is why this overlaps with API debt: an event envelope leaking into domain logic is the same defect as a database row leaking into a public response, and it has the same eventual cost.

Local Development Divergence

Local emulators reproduce the happy path and almost none of the interesting behaviour. They do not reproduce cold starts, so an initialisation bug that only fires on a fresh environment never appears. They do not reproduce concurrency, so state accidentally shared between invocations looks correct. They do not reproduce identity, because the emulator runs with your developer credentials rather than the function's restricted role -- which is why a permissions failure is such a common first production error.

They also do not reproduce the vendor's own timeouts, payload ceilings, throttles, or retry behaviour. Everything that is genuinely different about the platform is exactly what the emulator abstracts away.

The pragmatic answer is to stop trying. Unit test the domain logic with no cloud at all, which the adapter boundary above makes easy, and run integration tests against real ephemeral cloud resources in a per-developer or per-branch environment. Emulators are useful for a fast inner loop and should never be the last gate before production.

Observability Without a Process

Every technique built around a long-lived process stops working here. There is nothing to attach a profiler to, no heap graph to watch trend upward across a week, no thread dump to take, and no host metric that means anything, because the host is gone before you look. Local aggregation is unavailable too: a sidecar or in-process buffer that flushes every ten seconds may never flush at all, because the environment is frozen between invocations and may be destroyed without warning.

What replaces it is discipline about emitting context at the point of execution. Structured logs with a correlation identifier propagated across every hop, distributed tracing that survives asynchronous boundaries such as queues and event buses, and custom metrics emitted from the handler rather than inferred from the platform.

The failure that catches teams out is the asynchronous seam. A trace that stops at the queue boundary tells you a message was published and nothing about what happened to it, so the most common serverless incident -- work silently lost between two functions -- is invisible in exactly the tool you bought to find it. See observability debt for the wider pattern.

Timeout and Payload Ceilings Shape Architecture

Platform limits are documented facts, not surprises -- but they are usually read after the design is fixed rather than before, and by then they have already shaped the system. AWS Lambda caps a function at 900 seconds, with a 6 MB payload for synchronous invocations and 1 MB for asynchronous ones, memory from 128 MB up to 10,240 MB, and a deployment package of 50 MB zipped or 250 MB unzipped. Cloudflare Workers gives each isolate 128 MB of memory, a compressed script size of 3 MB on the free plan and 10 MB on paid, and a CPU budget of 10 milliseconds per request on the free plan against a paid default of 30 seconds.

The debt is not the limit. The debt is what teams build when they hit one late. A report generator that runs slightly too long gets chunked into a self-invoking function that calls itself with a continuation token, which is a distributed loop with no supervisor, no overall timeout, and no way to know whether it finished. A payload slightly over the ceiling gets pushed to object storage with a pointer passed instead, which is a reasonable pattern that becomes an orphaned-object problem when the consumer fails and nothing cleans up.

// The pattern that appears when a job outgrows the timeout.
// It works, it ships, and it is now permanent architecture.
export const handler = async (event) => {
    const cursor = event.cursor ?? null;
    const { items, next } = await processBatch(cursor, { limit: 500 });

    if (next) {
        // Re-invoke self. No supervisor, no overall deadline, no completion signal.
        // If ONE link in this chain fails, the job stops silently, halfway done,
        // and the only evidence is an absence in a log nobody is reading.
        await lambda.invoke({
            FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME,
            InvocationType: 'Event',
            Payload: JSON.stringify({ cursor: next }),
        });
    }
    return { processed: items.length };
};

The honest response to hitting a ceiling repeatedly is to change the execution model, not to tunnel under the limit. Long-running work belongs in a container service, a batch service, or a real workflow engine with durable state and a visible completion status. Self-invocation chains and multi-stage payload smuggling are not clever workarounds; they are undocumented distributed systems built by accident, and they will be discovered during an incident by somebody who has never seen them before.

State Machines That Became Unreadable

Managed orchestration solves a genuine problem: durable state across steps that outlive any single function. AWS Step Functions Standard workflows can run for up to a year, with a state machine definition capped at 1 MB and state input and output at 256 KiB. Those are generous numbers, and generous numbers are how you end up with a definition that has grown past what anyone can hold in their head.

The debt has a specific shape. Business logic migrates out of tested code and into the definition, where it is expressed in JSON path conditions, retry policies, and choice states -- none of which have a unit test, a type checker, or a code reviewer who reads them carefully:

{
  "Comment": "Order fulfilment. Grew from 4 states to 60 over three years.",
  "States": {
    "CheckEligibility": {
      "Type": "Choice",
      "Choices": [
        {
          "And": [
            { "Variable": "$.order.total",  "NumericGreaterThan": 500 },
            { "Variable": "$.customer.tier","StringEquals": "gold" },
            { "Variable": "$.order.region", "StringEquals": "EU" }
          ],
          "Next": "ManualReview"
        }
      ],
      "Default": "AutoApprove"
    }
  }
}

That condition is a business rule about who needs manual review. It is untestable without deploying, it will not appear in any search for the words that describe it, and when it changes there is no test that fails. Nobody chose to put pricing policy in a JSON document; it arrived one small edit at a time.

Keep orchestration definitions structural: sequence, parallelism, retry, compensation, and timeouts. Push every predicate about the business into a function that can be unit tested and returns a decision the state machine merely routes on. If a definition needs a diagram to be explained, it has outgrown the format, and the next person to modify it under incident pressure will get it wrong.

Edge Runtime Limits

Edge runtimes are not small servers. They are isolate-based sandboxes with a restricted API surface, and the debt is almost always incurred by discovering that late -- typically when a dependency four levels down the tree reaches for something that is not there.

The filesystem story is a good example of how fast this moves. It was true for years that Workers had no filesystem at all; today the node:fs module is available with nodejs_compat enabled and a recent compatibility date. The lesson is not the current answer, it is that "check the runtime documentation for the compatibility date you actually deploy with" has to be a step in your design process, not folklore repeated between engineers.

Storage consistency is the sharper edge. Cloudflare KV is eventually consistent, and the documentation is explicit that a write may take up to 60 seconds or more to be visible in other locations. Code written against a mental model of a database will pass every test in one region and produce read-your-own-writes bugs in production that reproduce only for users in a different part of the world.

WebAssembly: Promise and Sharp Edges

WebAssembly is a genuinely good answer to several serverless problems. It starts fast, it is sandboxed by construction, it is language-agnostic, and it lets you run compute-heavy code at the edge that would otherwise be impractical.

The sharp edges are in the surrounding ecosystem rather than the core. Garbage collection is no longer one of them -- WasmGC is part of the WebAssembly 3.0 specification and ships in most major browsers -- but plenty of production toolchains still predate it and bundle their own collector, which is why module sizes remain a live concern against edge script-size limits.

The system interface is the moving part to watch. WASI has now had three milestone releases, of which 0.3 is the most recent, and host runtimes support different milestones at different times. Debugging remains materially worse than for native code, and observability tooling assumes a host runtime you may not have. Adopt WASM for a bounded, compute-heavy component with a clear payoff. Adopting it as a general application platform means signing up to track a specification that is still moving.

The Cost Model Is Itself the Debt

This is the part that has no equivalent anywhere else on this site. Serverless billing is a function of duration, allocated memory, and invocation count. Every one of those is something your code controls, which means inefficiency stops being a maintenance problem and becomes a recurring charge that scales with your success.

On a fixed fleet, a handler that wastes a hundred milliseconds waiting on a dependency you could have called in parallel costs nothing extra -- the servers were already running and the waste is absorbed by headroom you already bought. Under per-invocation billing there is no headroom to absorb it. The waste is billed, on every request, and the more successful the product becomes the more it costs. Growth amplifies the mistake rather than diluting it.

Three specific patterns account for most of it:

Billed Waiting

Sequential awaits on independent calls. The function is billed for wall-clock duration whether it is computing or idle, so serial I/O is pure billed idleness. Running independent calls concurrently is often a one-line change.

Guessed Memory

Memory is set once and never revisited, but it also determines CPU share, so the cheapest setting is frequently not the smallest -- more memory can finish faster and bill less overall. Measure per function rather than standardising on a number.

Retry Amplification

A handler that throws on permanently bad input is retried by the platform, and each retry is billed at full price. A poison message can bill continuously for hours while producing nothing. Classify errors, or pay for the ones you cannot fix.

The organisational consequence is that serverless cost work has to be continuous rather than periodic. A quarterly cost review catches an over-provisioned VM before it has done much damage. It catches a wasteful function only after that function has been billed for every request in the quarter. Put per-function cost on the same dashboard as per-function latency, make it visible to the team that owns the function, and treat a cost regression the way you would treat a latency regression.

Related Resources

Frequently Asked Questions

They are debt whenever they are permanent and undated. Both are legitimate short-term mitigations for a real latency problem, and both stop being mitigations the moment nobody remembers why they exist. A scheduled warmer bills you for invocations that do no work, at a concurrency level somebody guessed at once, and it becomes load-bearing because removing it regresses latency. Provisioned concurrency is the paid version of the same trade. Treat either as acceptable only with a written expiry date and a linked ticket for the initialisation work it is compensating for. If cold starts remain unacceptable after that work, the honest conclusion may be that this workload belongs on a container service rather than in a function.

Do not start with a refactor. Start with an inventory: every function, its owning team, its trigger, its last deployment, and its invocation count over a full billing period. Anything with no invocations and no owner is a deletion candidate and usually the majority of the easy wins, since unowned functions are also unpatched and unmonitored ones. Next, enforce owner and purpose tags at deploy time so the inventory cannot rot again. Only then introduce a shared middleware package for logging, metrics, and error classification, adopted opt-in as each function is touched for other reasons. A big-bang standardisation of hundreds of functions will not finish, and an unfinished migration leaves you maintaining two conventions instead of one.

Real, but rarely where people look for it. The function code is the portable part and is almost never what blocks a migration. What blocks it is the accumulated identity and policy configuration encoding your authorisation model in one vendor's grammar, the managed services around the compute, the delivery semantics your code silently depends on, and the logic living in authorisers, event filters, and routing configuration that is not unit tested anywhere. Chasing full portability usually wastes a quarter for no benefit. The cheap, high-value move is an adapter boundary: one file per trigger type translates the vendor envelope into your own shape, and domain logic never sees the vendor at all. That also makes the logic testable without any emulator, which pays for itself long before any migration.

Because emulators reproduce the happy path and abstract away everything that is actually different about the platform. They do not reproduce cold starts, so initialisation bugs that only fire on a fresh environment never appear. They do not reproduce concurrency, so state accidentally shared between invocations looks correct. They run with your developer credentials rather than the function's restricted role, which is why a permissions failure is such a common first production error. They also do not reproduce timeouts, payload ceilings, throttling, or platform retry behaviour. Use emulators for a fast inner loop, unit test domain logic with no cloud involved at all, and run integration tests against real ephemeral cloud resources in a per-branch environment before anything reaches production.

When you find yourself engineering around the execution model rather than within it. Concrete signals: work that repeatedly approaches the maximum function duration, self-invocation chains used to continue a job past a timeout, payloads routinely pushed through object storage to dodge a size ceiling, permanent provisioned concurrency that never gets reviewed, or steady predictable traffic where per-invocation billing is simply more expensive than a reserved instance. Serverless is excellent for spiky, event-driven, short-lived work. Long-running, stateful, or high-throughput steady workloads are usually cheaper and considerably simpler on a container or batch service. Moving one workload is not an admission of failure, and it is far better than an undocumented distributed system assembled from workarounds.

It is ready for bounded, compute-heavy components with a clear payoff, and it is a bigger commitment as a general application platform. The core has matured substantially: garbage collection is part of the WebAssembly 3.0 specification and ships in most major browsers, so the old objection about bundling a collector applies mainly to older toolchains. The moving part is the system interface. WASI has now had three milestone releases, host runtimes support different milestones at different times, and the language toolchains sit at different points on that curve. Debugging is still materially worse than for native code, and most observability tooling assumes a host runtime you may not have. Adopt it where the performance or sandboxing win is specific and measurable, not as a default.

Inefficiency Here Is a Recurring Charge

Inventory the estate, tag every owner, put an adapter at the vendor boundary, and treat a cost regression exactly like a latency regression.