Node.js vs Python vs Go: Choosing a Serverless Runtime
Node.js 22, Python 3.12, and Go 1.22 run container-backed behind one gateway. Here are the differences that actually matter when you choose — ecosystem, streaming, cold starts, and Go's compile-to-plugin caveat.
“Which language should I write my serverless functions in?” is a question with a boring correct answer — the one your team already knows — and a more interesting real answer, which is that the runtime you pick changes what your functions can do, how they cold-start, and how they stream. On Inquir you can run Node.js 22, Python 3.12, and Go 1.22, mix them freely behind one gateway, and pick per function. This post is about the differences that actually matter when you choose.
First, the thing they have in common, because it is the foundation for everything else.
They are container-backed, not edge isolates
Every function runs in its own container — one container per function, isolated from the rest. That is a deliberate choice with real consequences, and it is the main axis on which container-backed serverless differs from edge/isolate platforms.
Edge runtimes (V8 isolates and friends) are wonderful for tiny, fast, stateless request handlers, but they impose sharp limits: a restricted subset of the standard library, no native modules, tight CPU/memory ceilings, and no arbitrary binaries. The moment you need sharp for image processing, a native crypto library, a headless browser, an ONNX model, or a package that shells out to a binary, you have outgrown the isolate model.
Container-backed functions do not have that ceiling. You get the full language runtime and standard library, native modules, and the ability to attach shared dependency layers for Node, Python, or Go. The trade is a cold start measured in container-startup time rather than isolate-instantiation time — which is exactly what hot/warm pools exist to hide. If your work is “small pure-JS transform at the edge,” an isolate is great. If it is “real backend work with real dependencies,” you want containers.
Everything below assumes that shared foundation: same gateway routing, same per-function secrets, same observability, same hot pools, same limits (256 MB default memory up to 2 GB; 5 s default timeout up to 15 min). What changes is the language.
Node.js 22 — the default for most backends
Node is the path of least resistance, and for most API-and-glue work that is the right call. The ecosystem is enormous, the async model matches request/response backends, and the handler is exactly what you expect:
export async function handler(event, context) {
const body = JSON.parse(event.body ?? '{}');
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}
A few Node specifics worth knowing:
- ESM and CommonJS both work, with auto-detection — you can ship
export async function handlerorexports.handler = …and it resolves either way, including a fallback that rescues a mis-labelled.mjs/.cjs. - Streaming is first-class via async generators (
export async function* handler) — see the SSE post for the full pattern. This makes Node the smoothest choice for LLM token streaming and agent progress. - Dependencies come from your bundle or from shared layers resolved on the module path, so heavy
node_modulesdo not have to be re-uploaded per function.
Reach for Node when the function is HTTP glue, a webhook handler, an AI-agent tool endpoint, or anything where “the library I need is on npm” is the deciding factor. That is most functions.
Python 3.12 — data, ML glue, and the AI toolchain
Python earns its place wherever the libraries are the point. Data wrangling, numerical work, ML inference, and the AI/LLM tooling ecosystem all live most comfortably in Python, and a container-backed runtime lets you use them without fighting an isolate’s restrictions.
def handler(event, context):
body = json.loads(event.get("body") or "{}")
return {"statusCode": 200, "body": json.dumps({"ok": True})}
Python specifics:
- Sync or async handlers — a plain
def handler(event, context)orasync defboth work, and async/generators give you streaming the same way Node does. - Layers mount onto
sys.path, so shared dependencies resolve like normal imports, with optional bytecode precompilation to trim import time. Bring the libraries you need as a layer rather than fattening every function. - It is the natural home for CPU-bound numerical work and model inference — load a model once at module scope so it is warm for subsequent invocations, and keep a warm pool so you are not paying cold import cost on every call.
Reach for Python when the function’s value is a library — a model, a parser, a scientific package — or when it is the glue between an LLM and your data.
Go 1.22 — performance, low memory, and one real caveat
Go is the choice when execution cost matters: CPU-bound work, high call volumes, tight memory budgets, or latency you want to shave to the floor. It runs lean and fast. It also has the most distinct model of the three, and one caveat you should know before you commit.
func Handler(event map[string]any, ctx obs.Context) (any, error) {
return map[string]any{"ok": true}, nil
}
Go specifics:
- Distinct
(result, error)contract. The handler returns a value and an error instead of a response object; panics are recovered so one bad request does not take the container down. - It compiles to a plugin. Your Go source is built into a plugin the first time it loads, cached by a hash of the source, and it requires
CGO_ENABLED=1. That first-load compile is a cost the interpreted runtimes do not pay — subsequent loads hit the cache. Plan for a slightly heavier first deploy in exchange for fast steady-state execution. - Streaming works differently. Go streams by returning a channel that the runtime drains into the SSE response — a better fit for Go’s concurrency model than a generator. Caveat: Go streaming requires the warm/hot execution path; the cold path rejects streaming. For a latency-sensitive streaming endpoint you keep a warm pool anyway, so this is usually a non-issue — but know it before you pick Go for a streaming service.
Reach for Go when the function is hot, numeric, or performance-critical and you would rather spend a compile step up front than CPU cycles forever.
A quick decision guide
- Default to Node.js for HTTP handlers, webhooks, AI-agent tools, and anything where the deciding factor is “the package is on npm.” It also has the smoothest streaming story.
- Choose Python when a library is the reason the function exists — data processing, ML inference, or LLM/data glue.
- Choose Go for CPU-bound, high-throughput, or memory-tight work, accepting a first-load compile and the cold-path streaming caveat.
And the quiet superpower of running all three on one platform: you do not have to choose once. A Python function can do the model inference, a Go function can do the hot numeric path, and a Node function can be the gateway-facing orchestrating tool — same routes, same secrets, same traces, different languages per job. Mixing runtimes is not a compromise; it is the point.
What stays the same regardless of language
Whichever runtime you pick, the platform contract is identical: functions sit behind the same API gateway with route-level auth, read the same per-function secrets as environment variables, emit the same execution traces and logs, run in isolated containers with the same memory and timeout limits, and benefit from the same hot/warm pools to hide cold starts. The language is an implementation detail of a single function — not a fork in your architecture.
So the honest answer to “which language?” is: pick per function, by what that function needs, and let them coexist. The one you already know is a fine default; the ability to reach for a different one when a specific job calls for it — without standing up a second platform — is the actual advantage.