How to Add Serverless Functions Without Melting Your Database

    5 min read3 approaches compared

    Serverless removes server management and replaces it with a set of constraints you now have to design around. Three of them cause nearly all the pain: functions can't hold connections, they can't run long, and they freeze rather than exit between invocations.

    None are dealbreakers, and the model is genuinely excellent for spiky workloads. But each one produces a failure that looks like something else — a database outage, a random timeout, a job that ran but didn't finish — so they're worth understanding before you're debugging one at peak traffic.

    Match the runtime to the work

    Serverless is not one thing. Node functions, edge functions, and background jobs have genuinely different capabilities, and the common mistake is using the wrong one.

    • Under a second, needs low global latency, no Node APIs (auth checks, redirects, personalization): edge runtime.
    • Ordinary API work needing npm packages and a database: standard Node/Python function behind a connection pooler.
    • Longer than the platform timeout (usually 10-60s): not a request handler. Use a queue and a worker.
    • Scheduled work: platform cron triggering a function, but make it idempotent — cron systems fire twice more often than you'd think.
    • Persistent connections, WebSockets, in-memory state: not serverless. Use a container.

    The approaches

    Standard functions (Vercel Functions, Lambda, Cloud Functions, Netlify)

    // best for: API endpoints, webhook receivers, and anything needing the full Node ecosystem.

    • Vercel
    • AWS
    • Google Cloud
    • Netlify
    • Azure
    • Next.js

    Full Node or Python runtime, any npm package, filesystem access to a temp directory. This is the workhorse, and for request/response work it's excellent — scaling is automatic and you pay per invocation.

    The defining constraint is connection handling. Each concurrent invocation is a separate process opening its own database connection, so 200 concurrent requests means 200 connections, and Postgres refuses them well before that. You need a transaction-mode pooler between the functions and the database, and this is architectural rather than an optimization.

    Initialize clients outside the handler. Module scope persists across invocations on a warm instance, so a client created there is reused; created inside the handler, you pay setup on every single request.

    typescript
    // Module scope survives between invocations on a warm instance.
    // Inside the handler, you'd pay this cost on every request.
    const db = createClient(process.env.DATABASE_URL!); // pooler URL
    
    export default async function handler(req, res) {
      const rows = await db.query("select 1");
    
      // Not awaiting this does NOT make it faster. The instance
      // freezes on return and the call may never complete —
      // or may resume inside an unrelated later invocation.
      await logAnalytics(req);
    
      res.json({ rows });
    }

    The gotcha

    Fire-and-forget doesn't work. The instance freezes the moment the handler returns, so an un-awaited promise is suspended mid-flight. It might complete on a later invocation, might never run, and produces analytics that are mysteriously ~30% short. Await everything, or hand it to a queue.

    Edge functions (Cloudflare Workers, Vercel Edge, Deno Deploy)

    // best for: Auth checks, redirects, A/B splits, and personalization at the CDN layer.

    • Cloudflare
    • Vercel
    • Netlify

    Edge functions run in V8 isolates rather than containers, in data centers close to the user. Startup is effectively zero — no cold start in the usual sense — and latency is tens of milliseconds worldwide. For work that must happen before the response starts, like checking a session cookie and redirecting, this is transformative.

    The restriction is the runtime. It's Web APIs — `fetch`, `Request`, `Response`, Web Crypto — not Node. No `fs`, no `net`, no Buffer in most cases, and any npm package depending on Node internals won't run. Most database drivers are out, which is why edge-compatible database access usually means an HTTP-based driver.

    There are also hard CPU-time limits per request, so edge is for decisions, not computation.

    The gotcha

    Bundle size limits are small (1MB compressed on Cloudflare's free tier) and a single large dependency blows past them. The failure happens at deploy, not in development, so it surfaces after the code is written and the dependency is entangled.

    Background jobs and queues

    // best for: Anything longer than a request timeout, or that must survive failure and retry.

    • AWS
    • Google Cloud
    • Redis
    • Railway
    • Render
    • Cloudflare

    The moment work exceeds the platform's timeout, it stops being a request handler. The pattern is: the handler validates, enqueues, and returns immediately; a worker consumes the queue with a much longer budget.

    This also buys you retries with backoff, which matters for anything touching a third party. A payment webhook that fails because Stripe blipped should retry, not vanish.

    The requirement queues impose is idempotency. Every queue worth using is at-least-once, meaning duplicate delivery is normal rather than exceptional. Each job needs a key that lets the worker recognize work it has already done — otherwise the retry that was supposed to save you charges the customer twice.

    The gotcha

    Assuming exactly-once delivery. Nearly all queues are at-least-once, and a job that times out mid-execution is redelivered while the first attempt may still be running. Every handler needs an idempotency key checked before it does anything with side effects.

    Common pitfalls

    Connecting directly to Postgres from functions

    Each invocation opens a connection and a spike exhausts the limit, producing 500s that look like a database outage. Put a transaction-mode pooler in front, and use the direct connection only for migrations.

    Assuming local filesystem persistence

    The temp directory is per-instance and vanishes. Writing an upload there and reading it on a later request works in development, where one warm instance handles everything, and fails randomly in production. Use object storage.

    Cron jobs that aren't idempotent

    Platform schedulers occasionally fire twice, and retries on failure are normal. A nightly billing job that isn't idempotent double-charges. Guard with a lock or a processed-marker keyed on the period.

    Long cold starts from heavy imports

    Importing a large SDK at module scope runs on every cold start. Import lazily inside the branch that needs it, so the common path doesn't pay for the rare one.

    Questions people actually ask

    How do I avoid database connection exhaustion?

    A transaction-mode pooler between the functions and the database — PgBouncer, Supabase's pooler, RDS Proxy, or an HTTP-based driver. This is required architecture in serverless, not tuning.

    Are cold starts a real problem?

    For JavaScript and Python, usually tens to low hundreds of milliseconds — noticeable but rarely disqualifying. For JVM or .NET they can be seconds. Edge runtimes essentially don't have them. If you have a strict latency SLO, measure the p99, not the median.

    When should I use a container instead?

    Persistent connections like WebSockets, in-memory state or caches, work that regularly exceeds the timeout, or steady high traffic where per-invocation billing costs more than an always-on instance.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides