Making an aws lambda node.js llm api call forces you to respect constraints absent in always-on services: sub-second cold starts, a hard timeout ceiling, and no connection pooling across invocations. This guide builds a minimal Node.js Lambda that posts to an OpenAI-compatible inference endpoint, parses usage metadata, and fails safe under provider errors.
Prerequisites
- AWS account with CLI v2 configured (
aws sts get-caller-identityreturns your ARN) - Node.js 18+ locally (Lambda runtime
nodejs18.xships globalfetch) - An API key for the LLM gateway; we’ll inject it via Lambda environment variables
- Familiarity with either the Lambda console or
zip-based deployment via CLI
No npm dependencies are required. Adding layers or packages increases cold-start latency for zero benefit here.
Project scaffold
mkdir lambda-llm && cd lambda-llm
npm init -y
Create a single file index.mjs. Keeping the deployment artifact tiny matters: Lambda unpacks your zip on every cold start.
Handler code
The target here is n4n.ai, which exposes one OpenAI-compatible endpoint covering 240+ models and applies automatic fallback when a backing provider is degraded. The request shape is identical to OpenAI’s /chat/completions.
// index.mjs
const BASE_URL = process.env.LLM_BASE_URL || "https://api.n4n.ai/v1";
const MODEL = process.env.LLM_MODEL || "openai/gpt-4o-mini";
export const handler = async (event) => {
const prompt = event.prompt ?? "Explain serverless cold starts in one sentence.";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); // stay under Lambda timeout
try {
const resp = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
// forward cache hint if caller supplied
...(event.cache_control ? { "X-Cache-Control": event.cache_control } : {}),
},
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: prompt }],
max_tokens: 200,
}),
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text();
return { statusCode: resp.status, error: text };
}
const data = await resp.json();
return {
statusCode: 200,
content: data.choices[0].message.content,
usage: data.usage, // per-token metering returned by gateway
};
} catch (err) {
return {
statusCode: 500,
error: err.name === "AbortError" ? "upstream_timeout" : err.message,
};
} finally {
clearTimeout(timeout);
}
};
Why AbortController
Lambda terminates the process at the timeout, but an unresolved fetch can hold an ephemeral socket. Aborting at 80% of the configured timeout (8s for a 10s function) gives the runtime time to flush logs and free the event loop.
Local smoke test
Run the handler outside AWS to validate shape and catch auth errors early:
LLM_API_KEY=sk-test LLM_BASE_URL=https://api.n4n.ai/v1 node -e '
import("./index.mjs").then(async (m) => {
const out = await m.handler({ prompt: "What is 2+2?" });
console.log(JSON.stringify(out, null, 2));
});
'
Expected output:
{
"statusCode": 200,
"content": "2 + 2 equals 4.",
"usage": {
"prompt_tokens": 12,
"completion_tokens": 8,
"total_tokens": 20
}
}
If you see statusCode: 401, your key is wrong. 429 means you hit a rate limit; the gateway’s fallback will route around degraded providers on later attempts.
Package and deploy
Zip the handler. No node_modules means a few hundred bytes.
zip -r function.zip index.mjs
Create the function (replace the role ARN with one granting lambda:InvokeFunction and CloudWatch Logs):
aws lambda create-function \
--function-name llm-proxy \
--runtime nodejs18.x \
--handler index.handler \
--role arn:aws:iam::123456789012:role/lambda-exec \
--zip-file fileb://function.zip \
--timeout 10 \
--memory-size 256 \
--environment "Variables={LLM_API_KEY=sk-live,LLM_MODEL=openai/gpt-4o-mini}"
Memory at 256 MB is enough for a single JSON payload and fetch. Bump it only if you parse large responses.
Invoke from CLI
aws lambda invoke --function-name llm-proxy \
--payload '{"prompt":"Name three AWS regions"}' out.json
cat out.json
Expected:
{
"statusCode": 200,
"content": "us-east-1, eu-west-1, ap-southeast-2.",
"usage": { "prompt_tokens": 15, "completion_tokens": 12, "total_tokens": 27 }
}
Hardening the aws lambda node.js llm api call
A demo function and a production integration differ in failure handling. Address these before shipping:
- Timeout discipline: Lambda timeout 10s, abort at 8s. Never let the upstream hang the invocation.
- Secret storage: Env vars are fine for prototypes. For production, pull from Secrets Manager once per cold start and cache in a module-level variable.
- Idempotency: Async Lambda retries on failure. If the call has side effects, check
event.requestContext.requestIdagainst a DynamoDB dedupe table. - Routing hints: Some gateways, including n4n.ai, honor client routing directives and forward provider cache-control hints, so passing
event.cache_controlthrough is free reliability.
Single retry on overload
Providers return 529 under heavy load. One immediate retry with a short backoff recovers most transient failures:
async function callWithRetry(body, attempt = 0) {
const resp = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify(body),
});
if (resp.status === 529 && attempt < 1) {
await new Promise((r) => setTimeout(r, 200));
return callWithRetry(body, attempt + 1);
}
return resp;
}
Wire callWithRetry into the handler in place of the direct fetch.
Streaming vs buffered
The code above buffers the full completion. If you need token streaming to a client, use a Lambda URL with awslambda.streamifyResponse and pipe the upstream SSE stream. That adds complexity: you must handle partial JSON and client disconnects. For batch jobs, queue processing, or agent tool calls, buffered responses are simpler and cheaper to operate.
Cost visibility
The usage object in the response is per-token metering from the gateway. Emit it as a structured log line:
console.log(JSON.stringify({ metric: "llm_usage", model: MODEL, ...data.usage }));
CloudWatch Logs Insights can sum total_tokens by model to give you a daily spend estimate without a separate billing pipeline.
Cold start reality
With zero dependencies, cold start for this function is typically 120–250 ms on Node 18. The first fetch incurs DNS and TLS handshake. If you need consistent single-digit-millisecond latency, enable provisioned concurrency or SnapStart (Java only today, but watch the runtime updates). For most async workloads, on-demand is fine.
Final shape
That’s a complete aws lambda node.js llm api call path from local scaffold to deployed function, with timeout discipline, retry on overload, and per-token usage emitted for observability. Swap the base URL and model name to point at any OpenAI-compatible endpoint; the error handling stays identical.