all posts

How to move from serverless functions to a long-running server

Ajay Kumar··9 min read

Functions are excellent at what they're for: short, stateless, request-scoped work with variable traffic. The migrations happen when an app grows a part that isn't that — and the tell is usually one of four symptoms, each of which points at the same conclusion from a different direction.

This is how to do that move without turning it into a rewrite. I build PandaStack, so the deploy commands are mine; the approach works on any platform that runs a process.

The four symptoms

  • Timeouts. A job, report, or import that keeps hitting the maximum duration, and each fix buys you a few more months before it hits it again.
  • Cold starts on user-facing paths. A dependency tree that takes seconds to import, on a request that matters.
  • Database connection exhaustion. Every concurrent invocation opens its own connection, and a traffic spike empties the connection limit — a database outage caused entirely by architecture.
  • Work you keep faking. A queue consumer implemented as a function that polls on a schedule. A WebSocket you route through a third-party service. An in-memory cache that never hits because every invocation is cold.

If you recognise two or more of these, the workload wants a process. That's not a failure of judgement — it's a workload that changed shape.

Step 1: move the smallest thing that fixes it

The instinct is to migrate the whole app. Resist it. Most function-based apps have one or two handlers causing all the pain and a dozen that are perfectly happy — thin CRUD endpoints, webhook receivers, scheduled cleanups. Those are cheap, scale automatically, and cost you nothing to leave alone.

Sort your functions into two lists: those where an invocation is genuinely a complete unit of work, and those where you've been working around the absence of a process. Move only the second list.

A hybrid architecture is a legitimate destination, not a transitional state. Functions at the edge for routing and thin endpoints, a long-lived server for the heavy or stateful parts. Plenty of well-run systems look exactly like this permanently.

Step 2: unpick the framework wrapper

Handlers written against a provider's signature need a thin adaptation. This is usually less work than expected, because the business logic rarely touches the event object beyond parsing it.

// Before: a provider-shaped handler
export async function handler(event) {
  const body = JSON.parse(event.body ?? "{}");
  const result = await processOrder(body);
  return { statusCode: 200, body: JSON.stringify(result) };
}

// After: the same logic behind an ordinary route.
// processOrder didn't change — only the shell around it.
app.post("/orders", async (req, res) => {
  const result = await processOrder(req.body);
  res.json(result);
});

If your handlers are thick with provider-specific plumbing, extract the logic into plain functions first, as a separate change, while still on the old platform. Doing the extraction and the migration at once means you can't tell which one broke something.

Step 3: things you can suddenly do (and should)

  • One shared connection pool instead of a connection per invocation. This alone often removes the reason you migrated.
  • In-memory caching that actually hits, because the process lives between requests.
  • Expensive setup — a model load, a config fetch, a compiled template cache — done once at startup rather than on every cold start.
  • Background work in the same process: a queue consumer, a periodic reconciliation, a metrics flush.
  • Long-lived connections: WebSockets, server-sent events, a subscription to Postgres change notifications.

Step 4: things you're now responsible for

This is the honest half. Functions were handling several things silently, and you're taking them back.

  1. Scaling. The platform auto-scaled per invocation; now you decide instance counts and thresholds. Start with more headroom than you think and tune down.
  2. Memory leaks. In a function, a leak is cleaned up when the invocation ends. In a process that runs for weeks, it grows until the OOM killer arrives. Watch resident memory over days, not minutes.
  3. Graceful shutdown. Deploys send SIGTERM; without a handler, in-flight requests are cut.
  4. Concurrency bugs. Module-level state was per-invocation and is now shared across every concurrent request. This is the class of bug most likely to surprise you — a cached user object at module scope is a data leak between requests.
  5. Restarts. Nothing restarts the process for you between requests any more, which is exactly what you wanted and also means state accumulates.
Module-level mutable state is the migration's real hazard. Under functions, a variable at module scope was effectively request-scoped most of the time. In a long-lived server it is shared across every concurrent request, and the bug it produces — one user seeing another's data — is severe, intermittent, and invisible in a low-traffic staging environment.

Step 5: deploy and cut over

# Deploy the extracted service as a long-lived process
pandastack apps create --name orders-worker \
  --git-url https://github.com/acme/orders \
  --build-cmd 'npm ci && npm run build' \
  --start-cmd 'node dist/server.js'

# Route the affected paths to it from your existing edge function
# or API gateway; leave every other function exactly where it is

Cut over one route at a time, behind whatever routing layer you already have. Run both in parallel for the first week and compare error rates. Because you moved a subset, a rollback is a routing change rather than a redeployment of everything.

The short version

Identify which functions were fighting the model, move only those, extract business logic from handler plumbing as a separate step, and take the wins — shared pools, real caching, one-time startup cost. Then accept the new responsibilities honestly: scaling, memory over time, shutdown handling, and shared state. That last one is where the real bugs live, and it's worth an explicit audit before you send production traffic.

Frequently asked questions

When should I move off serverless functions?

When you recognise two or more of four symptoms. Timeouts on work that keeps growing past the maximum duration, where each optimisation buys months rather than solving it. Cold starts landing on user-facing paths because the dependency tree takes seconds to import. Database connection exhaustion, because every concurrent invocation opens its own connection and a traffic spike empties the limit. And work you keep faking — a queue consumer implemented as a polling schedule, a WebSocket routed through a third party, an in-memory cache that never hits. Any one of these might be worth optimising around; two or more means the workload wants a process.

Do I have to migrate my whole app off functions?

No, and you probably shouldn't. Most function-based applications have one or two handlers causing all the pain and a dozen that are entirely happy — thin CRUD endpoints, webhook receivers, scheduled cleanups — which are cheap, scale automatically, and cost nothing to leave alone. Sort your functions by whether an invocation is genuinely a complete unit of work, and move only the ones where you have been compensating for the absence of a process. A hybrid architecture, with functions at the edge and a long-lived server behind them, is a legitimate permanent design rather than a halfway house.

What breaks when moving from functions to a long-running server?

Module-level mutable state, more than anything else. Under functions, a variable declared at module scope was effectively request-scoped most of the time because the execution environment was short-lived and rarely concurrent. In a long-lived server it is shared across every concurrent request, so a cached user object or a request-specific value stored at module scope becomes a data leak between users. The bug is severe, intermittent, and nearly invisible in a low-traffic staging environment. Audit for module-scope mutable state explicitly before sending production traffic, and treat anything you find as a security issue rather than a tidiness one.

Will a long-running server fix my database connection problems?

Usually, yes — and it is often the single biggest win of the migration. Functions open a connection per concurrent invocation, so your connection usage tracks your traffic spikes directly and the database's limit becomes a scaling ceiling you cannot raise without paying for a much bigger instance. A long-lived process holds one pool shared by every request it handles, so a few instances with modest pools serve far more traffic than the same workload did as functions. Size the pool against the total across all instances rather than per instance, and leave headroom for migrations and your own sessions.

What do I have to manage myself after leaving serverless?

Five things the platform was doing quietly. Scaling decisions, since instance counts and thresholds are now yours — start with more headroom than you expect and tune down with evidence. Memory over time, because a leak that was cleaned up when an invocation ended now grows for weeks until the OOM killer intervenes. Graceful shutdown, because deploys send SIGTERM and an unhandled one cuts in-flight requests. Concurrency correctness, since state is now shared. And restarts, which no longer happen between requests — which is exactly the property you wanted, and also means anything that accumulates now accumulates.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.