124: Native Node HTTP/HTTPS, Fetch, URLs, Headers, Keep-Alive, and Consuming APIs
Learning objectives
You will learn to:
- understand the HTTP request/response lifecycle in Node;
- create an HTTP server with
node:http; - parse URLs and query parameters;
- work with request/response headers;
- understand HTTP bodies as streams;
- set status codes and content types;
- use
fetchto consume HTTP APIs; - use AbortSignal/timeouts;
- understand connection reuse/keep-alive at a practical level;
- distinguish transport errors from HTTP errors;
- understand redirects, compression, proxies, and TLS at a high level;
- build an API client boundary.
Why native HTTP before Express
Express builds on Node HTTP concepts.
If you understand:
IncomingMessage ServerResponse method URL headers body stream status socket
then Express is a convenience layer, not magic.
First server
import http from 'node:http';
const server = http.createServer((request, response) => {
response.statusCode = 200;
response.setHeader('content-type', 'text/plain; charset=utf-8');
response.end('Hello\n');
});
server.listen(3000, '127.0.0.1', () => {
console.log('http://127.0.0.1:3000');
});
The callback runs for each request.
IncomingMessage
request includes:
method url headers socket body stream
console.log(request.method);
console.log(request.url);
console.log(request.headers);
Do not trust headers because they came from the network.
URL parsing
request.url is relative path/query.
Build URL with known origin:
const url = new URL(
request.url,
`http://${request.headers.host ?? 'localhost'}`,
);
For security-sensitive origin/host logic behind proxies, do not blindly trust the Host header. Use deployment-aware configuration.
Read:
url.pathname
url.searchParams.get('page')
Routing manually
if (request.method === 'GET' && url.pathname === '/health') {
response.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
});
response.end(JSON.stringify({ ok: true }));
return;
}
Manual routing becomes cumbersome. That is one reason frameworks exist.
JSON response helper
function sendJson(response, status, body) {
const text = JSON.stringify(body);
response.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(text),
});
response.end(text);
}
Content-Length is bytes.
Do not use string length.
Reading request body with a limit
async function readJson(request, maxBytes = 1_000_000) {
const chunks = [];
let total = 0;
for await (const chunk of request) {
total += chunk.length;
if (total > maxBytes) {
const error = new Error('Request body too large');
error.status = 413;
throw error;
}
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
return text ? JSON.parse(text) : null;
}
This still buffers up to limit, which is acceptable for small JSON.
Large uploads should stream.
Content-Type
Do not parse arbitrary request bodies as JSON solely because endpoint expects JSON.
Check media type:
application/json
and reject unsupported types with appropriate status such as 415.
Real content-type parsing includes parameters:
application/json; charset=utf-8
Use robust parsing/framework utilities.
Status codes
Common:
200 OK 201 Created 202 Accepted 204 No Content 400 Bad Request 401 Unauthorized (actually unauthenticated in common API usage) 403 Forbidden 404 Not Found 409 Conflict 413 Content Too Large 415 Unsupported Media Type 422 Unprocessable Content 429 Too Many Requests 500 Internal Server Error 503 Service Unavailable
Choose status from semantics.
Do not return 200 { success:false } for every failure.
Headers
Node normalizes access, but HTTP header names are case-insensitive.
Examples:
request.headers.authorization
request.headers['content-type']
Response:
response.setHeader('cache-control', 'no-store');
Never reflect arbitrary user header values into response headers without validation; header injection/splitting protections and semantics matter.
Cookies
Cookie is an HTTP header:
Cookie: session=...
Set:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
Use a mature cookie/session library for production parsing/signing.
Security attributes matter.
HTTPS
node:https supports TLS.
In production, TLS often terminates at:
- reverse proxy;
- load balancer;
- CDN;
- ingress.
Your Node app may receive plain HTTP from a trusted local proxy.
Understand where TLS terminates and how client scheme/IP is forwarded.
Do not trust X-Forwarded-* from arbitrary internet clients unless proxy trust is configured.
Keep-alive
HTTP connection reuse avoids TCP/TLS setup per request.
Modern Node fetch and HTTP internals manage connection pooling/keep-alive.
Do not create a brand-new custom Agent for every outbound request; that can defeat pooling.
When using third-party HTTP clients, understand their connection-pool lifecycle.
fetch
Modern Node provides global fetch.
const response = await fetch('https://api.example.com/tasks');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await response.json();
Critical:
fetch() rejects on network/transport failures, not on HTTP 404/500.
You must check response.ok or status.
Timeout/cancellation
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new Error('upstream timeout'));
}, 3000);
try {
const response = await fetch(url, {
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
Use supported AbortSignal timeout helpers where appropriate.
Timeout must cancel request, not only stop waiting.
Upstream API client
export class HttpError extends Error {
constructor(message, { status, body, cause } = {}) {
super(message, { cause });
this.name = 'HttpError';
this.status = status;
this.body = body;
}
}
export async function requestJson(
url,
{
signal,
headers,
...options
} = {},
) {
let response;
try {
response = await fetch(url, {
...options,
signal,
headers: {
accept: 'application/json',
...headers,
},
});
} catch (cause) {
throw new HttpError('Upstream request failed', {
cause,
});
}
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
: await response.text();
if (!response.ok) {
throw new HttpError('Upstream returned an error', {
status: response.status,
body,
});
}
return body;
}
Now components/services do not repeat transport parsing.
Redirects
Fetch follows redirects by default according to its API.
For security-sensitive server-side fetch of user-provided URLs, redirects can become SSRF paths.
Example:
user URL initially public → redirect to 169.254.169.254 / internal service
Do not build arbitrary URL fetch proxies without SSRF protections.
SSRF
Server-Side Request Forgery occurs when attacker controls server outbound target.
Protection may include:
- allowlist destinations;
- parse URL;
- enforce scheme;
- resolve/validate IPs;
- block private/link-local/loopback ranges;
- revalidate redirects;
- network egress policy.
A regex like:
url.startsWith('https://')
is not sufficient.
API retries
Retry safe/idempotent operations carefully.
Potential retry:
GET
Often safe.
POST /payments can duplicate side effects unless server provides idempotency semantics.
Use exponential backoff/jitter and retry budgets to avoid retry storms.
HTTP caching
Response headers can express:
Cache-Control ETag Last-Modified Vary
Do not implement cache policy randomly.
Private/authenticated responses need careful cache directives.
Conditional requests can reduce bandwidth.
Compression
Servers can compress:
gzip br
but reverse proxies/CDNs often handle this more efficiently.
Compression has CPU cost and security considerations.
HTTP streaming
Request/response are streams.
Streaming JSON arrays is not ordinary JSON until complete.
For streaming structured data, use formats/protocols such as:
- NDJSON;
- SSE;
- chunked text;
- WebSocket.
Document client behavior.
Server-Sent Events
SSE uses long-lived HTTP response:
Content-Type: text/event-stream
Good for one-way server→client updates.
Requires heartbeat/reconnect/proxy timeout considerations.
WebSocket is bidirectional and is a different protocol upgrade.
Node roadmap focuses HTTP fundamentals; realtime can be an advanced extension.
Client disconnect
Request/response can close early.
Long work should support cancellation if client no longer needs result, especially expensive upstream/database operations.
Do not assume every accepted request remains connected.
Graceful server close
Native server can stop accepting new connections.
Production shutdown must account for keep-alive/in-flight sockets and deadlines. Covered later.
Common mistakes
- no request body limit;
- using string length for content length;
- fetch 500 treated as success;
- timeout without abort;
- trust proxy headers blindly;
- outbound URL SSRF;
- retry unsafe writes;
- buffer huge request/response;
- return raw internal error;
- custom HTTP client creates pools repeatedly.
Exercises
- Build native
/healthand/tasksGET routes. - Add JSON response helper.
- Parse JSON with 64 KB limit.
- Reject wrong Content-Type.
- Consume an external API with fetch and explicit HTTP error handling.
- Add abort timeout.
- Simulate redirect/SSRF threat and design allowlist.
- Add ETag concept to one read endpoint.
- Stream a large file response.
Mastery checklist
Explain:
- native HTTP server;
- request/response streams;
- URL parsing;
- body limits;
- headers/status;
- fetch network versus HTTP errors;
- cancellation;
- connection reuse;
- TLS/proxies;
- SSRF;
- retries/idempotency;
- HTTP caching.
