Netlify Server
Early Access, so APIs may change. It is available on credit-based plans and on Enterprise plans with credits enabled. It requires Node.js 24+ for both build and local dev, and Netlify CLI 27.17.0+ for both local dev and deploys.
It is free during Early Access and intended for evaluation, experimentation, and low-volume production use. At GA it will bill credits for reserved memory (GB-hours) and active CPU (vCPU-hours). Every request the server handles already counts toward the web requests meter.
Reach for this / avoid this
ts
// ✅ netlify/server/index.ts — handler (same signature as Netlify Functions)
import type { Context, Config } from "@netlify/server";
export default async (req: Request, context: Context) =>
new Response(`Hello from ${context.geo.city ?? "somewhere"}`);
export const config: Config = { path: "/api/*" };
js
// ✅ Fetchable app (Hono etc.): export as is
export default app;
// ✅ Node request listener: wrap it, do NOT call listen(). WebSockets work.
export default createServer(handleRequest);
// ✅ Framework that starts itself (Express/Fastify): listen on PORT within 10 s
app.listen(process.env.PORT);
js
// ❌ export default (req, res) => {...} // two params = treated as a fetch handler, breaks
// ❌ export default expressApp // works, but WebSocket upgrades get 501
// ❌ server.close(() => process.exit(0)) // cuts off shutdown hooks and background work
// ❌ setInterval / fire-and-forget promise // invisible to Netlify; the instance can stop mid-work
You don't configure ports, add health endpoints, or install adapters. Install the package with
npm install @netlify/server
.
Entry file and detection
- Use one server per project: , , , or . The build fails if more than one exists.
- Startup runs once per instance:
- is set.
- The entry file is imported, running top-level code (top-level is awaited).
- hooks run in order.
- The default export is inspected.
- No traffic arrives until all steps finish. Any throw fails the start.
Netlify uses the first matching row for the default export:
| Default export | Run as | WebSockets |
|---|
| without | Requests sent to it | event (, Socket.IO) |
| Object with (Fetchable, e.g. Hono) | | |
| Express/Connect app, or a function with ≥3 params | Wrapped in its own HTTP server | 501 |
| Any other function | (request, context) => Response
| |
| Anything else / nothing | Waits for your code to listen | event |
⚠️ Self-started servers must listen within 10 s after start hooks finish.
- If your code starts several servers, the one on wins. If none listens on it, the start fails.
- Reserved ports are and .
- Only Node.js HTTP servers are supported, not raw TCP.
Create shared resources such as a DB pool at top level so they are reused across requests.
⚠️ Outbound connections are closed after 60 s without data in either direction. TCP keepalives don't count.
- Set your pool's idle timeout below 60 s (e.g.
idleTimeoutMillis: 30_000
), or it hands out dead connections.
- Quiet connections such as Postgres need traffic at least every 60 s (e.g. ), or must reconnect on close. The symptom is
Connection terminated unexpectedly
.
Examples
js
// netlify/server/index.mjs — Express, self-started
import express from "express";
import { getContext } from "@netlify/server";
const app = express();
app.get("/api/hello", (req, res) => {
const { geo } = getContext();
res.json({ message: `Hello from ${geo.city ?? "somewhere"}` });
});
app.listen(process.env.PORT || 3000);
js
// Fastify
const app = Fastify();
app.get("/api/hello", async () => ({ message: `Hello from ${getContext().geo.city ?? "somewhere"}` }));
await app.listen({ port: Number(process.env.PORT) || 3000 });
Background work after responding
ts
export default async (req: Request, context: Context) => {
const order = await saveOrder(await req.json());
context.waitUntil(sendConfirmationEmail(order)); // keeps the instance alive until settled
return Response.json(order, { status: 201 });
};
// In frameworks: const { waitUntil } = getContext();
- It is not bound by the request duration limit.
- Rejections are logged.
- On replacement, background work gets up to 90 s. Persist critical work to Netlify Database or Netlify Blobs.
WebSockets (handler/Fetchable)
ts
import { upgradeWebSocket } from "@netlify/server";
export default async (req: Request) => {
if (req.headers.get("upgrade") === "websocket") {
const { socket, response } = upgradeWebSocket(req, { idleTimeout: 30 }); // pings off unless set
socket.onmessage = (e) => socket.send(`Echo: ${e.data}`);
return response; // return as is
}
return new Response("Connect with a WebSocket client");
};
WebSockets ( on a self-started server)
js
const server = app.listen(process.env.PORT);
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => ws.on("message", (d) => ws.send(d)));
// On shutdown, close wss.clients yourself. getWebSockets() doesn't include them.
Server-Sent Events / streaming
ts
export default async () => {
const enc = new TextEncoder(); let t: ReturnType<typeof setInterval>;
const stream = new ReadableStream({
start(c) { t = setInterval(() => c.enqueue(enc.encode(`data: ${new Date().toISOString()}\n\n`)), 1000); },
cancel() { clearInterval(t); },
});
return new Response(stream, { headers: { "content-type": "text/event-stream" } });
};
Client reconnect
Browser
doesn't reconnect on its own;
does.
js
function connect() {
const s = new WebSocket("wss://example.com/api/chat");
s.addEventListener("close", (e) => setTimeout(connect, e.code === 1012 ? 0 : 1000));
return s;
}
Lifecycle hooks
ts
import { onStart, onShutdown, onHealthCheck, getWebSockets } from "@netlify/server";
onStart(async () => { await db.connect(); }); // in order; rejection = start fails
onShutdown(async ({ drained }) => { // all hooks run in parallel
queue.stop();
for (const s of getWebSockets()) s.close(1012, "Server restarting");
await drained; // never rejects
await pool.end(); // close resources only after drained
});
onHealthCheck(() => ({ // load signal, not health
utilization: { db_pool: (pool.totalCount - pool.idleCount) / pool.options.max }, // 0–1
namedMetrics: { db_pool_waiting: pool.waitingCount }, // observability only
}), { interval: 1000 }); // default and minimum 1000 ms
Hook rules:
- must be called while the entry file loads. Calling it later throws.
- throws outside a request, including at top level and inside hooks.
- In health reports, and are reserved keys. (0–1) overrides the max entry.
- A health hook that throws never stops the instance. Health hooks don't run locally.
Edge caching
Responses are never cached unless you set headers.
ts
return Response.json(data, { headers: { "Netlify-CDN-Cache-Control": "public, max-age=3600, durable" } });
// Express: res.set(...) Hono: c.header(...) Fastify: reply.header(...)
Supported features:
- Cache keys varied by query, header, language, country, or cookie
- Invalidation by site or cache tag
Each deploy invalidates the previous cache.
Context ( arg or )
- : , , , , , , .
- (same as / ), (), .
- / / follow CookieStore semantics.
- : , , , , .
- : , , . . .
- : on private projects or with team login protection, otherwise .
- .
Types:
,
,
,
,
,
,
,
,
,
,
,
.
Configuration
Set config with
export const config: Config
in the entry file (or
on a default-exported Fetchable), or in
.
The entry file wins per setting. Unknown keys or invalid values
fail the build.
toml
[server]
region = "fra"
size = "medium"
[server.scale]
max = 2
| Key | Values / default |
|---|
| One or more paths starting with . Same syntax as function paths. Default |
| Paths handed back to the rest of the project, e.g. |
| (US East Ohio, default) or (Frankfurt). Also settable in the UI under Server → Region, but a region overrides it. Changes apply on the next deploy |
| (1 GB, shared vCPU, 512 MB disk, 32 concurrent requests, default); (2 GB/1 vCPU/1 GB/64); (4 GB/2 vCPU/2 GB/128). Free/Personal plans can use only |
| . must be 0 in Early Access. ≤ 3, or 1 on Free/Personal. Higher values fail the deploy |
| Globs for files read at runtime. Entry-file globs are relative to the entry file and replace the toml list. Read them via , never |
Other settings:
- Environment variables: use with variables scoped to Functions. Values are fixed at deploy time, so redeploy after changes.
- Filesystem: read-only except , which is per-instance and wiped on stop.
Routing
Requests are handled in this order:
- Edge functions
- Redirects/rewrites
- Static files
- Functions
- The server, which owns its own 404s
⚠️ Server gets no requests? Check these causes:
- An SPA catch-all swallows everything. Scope it to frontend paths.
- A static file or function already occupies the same path. Static files always win.
- / excludes the path.
- is reserved.
- Full-stack frameworks (Next.js, Astro, Nuxt, SvelteKit, React Router, TanStack Start) don't run on Netlify Server. Their adapters claim every path, so don't add a server to those projects.
Choosing between options:
- Use a server for a new backend.
- Use scheduled functions for cron jobs and webhooks.
- Use edge functions for logic close to visitors.
- Functions are cheaper for rarely used backends, because instances idle for 5 min before stopping.
Instances and lifecycle
Assume multiple instances. They overlap during deploys and are replaced every 8 h, and memory and
are per-instance. Practical consequences:
- In-memory state is a cache. Put shared data in Netlify Database or Netlify Blobs.
- Each instance holds its own WebSocket connections.
- Once-only work can run twice or never. Use a scheduled function and record completion.
Scaling and limits:
- Concurrency: depends on size: 32 (), 64 (), or 128 () requests per instance. Each open SSE/stream holds one of those slots. WebSockets don't use these slots. They have a separate limit of a couple of hundred per instance, and an instance full of WebSockets still serves ordinary requests.
- Routing: new requests go to the least-loaded instance, judged by event loop, memory, and health hooks.
- Scale to zero: an instance stops after 5 min idle. Open requests, WS/SSE connections, and work pause the timer; does not.
- Cold starts: the first request waits for the entry file and start hooks. That time counts toward the 20 s response timeout. Lazy-init route-specific services and keep dependencies small.
Shutdown sequence:
- New traffic stops.
- is sent.
- Your server stops accepting connections.
- Shutdown hooks start.
- In-flight work and connections get up to 90 s.
- Hooks get up to 115 s from their start (at least 25 s after work ends).
- Remaining connections are force-closed.
Close sockets with code
in
so clients move immediately.
Deploys:
- A production deploy publishes only after its new server has started. If the server fails to start, the deploy isn't published and the old one keeps serving.
- Rollback rolls back the server too.
- Deploy Previews and branch deploys start their server on first request. That request is a cold start, so slow startup can turn it into a 502.
Limits
| Limit | Value |
|---|
| Response timeout | 20 s to send status + headers, else 502. Cold-start time is included. Stream the body, or respond and finish with |
| Request duration (incl. WS/SSE) | 30 min; 2 h on Pro, Business, and Enterprise |
| Outbound connections | Closed after 60 s idle; keepalives don't count |
| Instance lifetime | 8 h |
| Instances | 3 (credit-based Pro/Enterprise); 1 (Free/Personal) |
Errors
| Status | Meaning / fix |
|---|
| 429 | All concurrency slots (32/64/128 by size) are full at max instances. Long SSE streams are often the cause |
| 500 | The instance failed to start (top-level/start hook threw, or no listen on PORT within 10 s), or the handler threw |
| 501 | WebSocket upgrade on a directly exported Express/Connect app. Use or |
| 502 | No headers within 20 s (including cold start), or the instance crashed/OOM'd |
Local dev and logs
Local dev:
- Run (CLI 27.17.0+). The Netlify Vite plugin doesn't run the server.
- The CLI restarts the server on changes inside only. Restart the CLI for changes elsewhere.
- Local differences from production:
- Single instance.
- / ignored.
- No limits enforced.
- 5 s shutdown.
- No on Windows.
- No health hooks.
- For E2E tests, use :
new NetlifyDev({ projectRoot, server: { enabled: true } })
, then / .
Monitoring and logs:
- Use the Server page under Cloud compute. It opens in Live mode (last hour) and shows production by default, and you can switch branches to see branch deploys or Deploy Previews.
- Overview: requests/errors, p95 latency, memory, CPU, and saturation.
- Instances table: state, start reason, and failed-start warnings.
- Requests view: cold start, queue wait, and Open in Observability.
- Logs view.
- Per-request views are also in observability.
- Use inside a request to attach the . Direct writes aren't linked to requests.
CLI and log drains:
bash
netlify logs # server + functions + edge functions, last 10 min
netlify logs --source server --since 30m # server only; --until ends the window
netlify logs --source server --follow # stream lines as written
- and work. and don't work with server logs, because they can't be filtered by deploy.
- Long windows show only the most recent lines. Narrow / to see older ones.
- For log drains, select Server logs as a log type.
<!-- Gaps: instance cap, medium/large size availability, and scale.max default for Business plans are not documented. -->
<!-- Exact per-instance WebSocket limit, netlify.toml spelling of excludedPath/includedFiles, and GA credit rates are unstated. -->
<!-- system: agent-context/server/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
Netlify house rules (server)
These are org conventions, not docs facts — they are merged into the rendered
skill by ctx-gen and are never generated. Owned by the Netlify Server team.
- Never keep a server alive on purpose: no self-pings, no or
scheduled function that calls the server, no chain that
schedules more work to stay running. Scale to zero is the design. If the
user asks for an always-on server, explain this and build without it.
- Run scheduled work with a scheduled function that calls a server route,
never with timers inside the server.
- An endpoint that fetches a URL the user supplies must resolve the hostname
and refuse loopback, private, link-local and unique-local addresses
(including 169.254.169.254), re-check after every redirect, and return only
what the feature needs. Never build a generic endpoint or
forward the caller's method, headers or body — that is an open relay.
- Process memory is lost on every scale-to-zero, deploy and platform
maintenance event. In-memory caches are fine; anything that must survive
belongs in Netlify Blobs or Netlify Database.
- Don't detach child processes; they are killed on scale-to-zero. Keep work
in-process with , or use a background function.