JavaScript Basics: Handle Fetch Responses with a Local HTTP Server
A successful fetch call does not by itself mean an HTTP request succeeded or that its JSON has the shape you expected. Use a tiny local server to practice checking status, consuming a response, and sending JSON without depending on a public service.
These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in modules and assertions, with no package installation. Each script briefly listens on 127.0.0.1 at an operating-system-selected port, sends a synthetic request only to that server, then closes it. Local loopback networking must be allowed; no external service or credentials are used. The output below is the observed output.
Prepare a scratch directory
You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep the same terminal open and save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Check the status and validate the parsed result
Save this as fetch-local-json.mjs:
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ items: ['pen', 'paper'] }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const url = `http://127.0.0.1:${server.address().port}/items`;
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
assert.equal(response.status, 200);
assert.equal(response.ok, true);
assert.equal(response.headers.get('content-type'), 'application/json');
assert.equal(response.bodyUsed, false);
const data = await response.json();
assert.deepEqual(data, { items: ['pen', 'paper'] });
assert.equal(response.bodyUsed, true);
console.log(`status: ${response.status}; ok: ${response.ok}`);
console.log(`items: ${data.items.join(',')}`);
console.log(`body consumed: ${response.bodyUsed}`);
} finally {
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections();
});
}Run it:
node fetch-local-json.mjsOutput:
status: 200; ok: true
items: pen,paper
body consumed: trueThe first await receives a Response; the second consumes its body and parses JSON. Parsing alone is not schema validation, so the example separately checks the exact data shape. The Fetch response model defines ok as a status from 200 through 299. The five-second signal bounds this small request rather than relying on an unlimited wait.
2. Handle an HTTP failure without confusing it with a network failure
Save this as fetch-local-not-found.mjs:
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
const server = createServer((_request, response) => {
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
response.end('sample item does not exist');
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const url = `http://127.0.0.1:${server.address().port}/missing`;
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
assert.equal(response.status, 404);
assert.equal(response.ok, false);
if (!response.ok) {
const detail = await response.text();
assert.equal(detail, 'sample item does not exist');
console.log(`handled HTTP ${response.status}: ${detail}`);
}
await assert.rejects(() => response.text(), { name: 'TypeError' });
console.log('reading the consumed body again: rejected');
} finally {
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections();
});
}Run it:
node fetch-local-not-found.mjsOutput:
handled HTTP 404: sample item does not exist
reading the consumed body again: rejectedThe fetch promise resolves even though the server returns 404. The explicit ok check selects the error path, and text() reads the known plain-text error body. After consumption, reading that same body again rejects. Choose how to consume it once and retain the parsed result if multiple callers need it. Network errors and cancellation instead require promise-rejection handling.
3. Send JSON with an explicit method and content type
Save this as post-local-json.mjs:
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
const server = createServer(async (request, response) => {
try {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 1024) throw new Error('Example request is too large');
chunks.push(chunk);
}
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const result = {
method: request.method,
contentType: request.headers['content-type'],
received: data,
};
response.writeHead(201, { 'content-type': 'application/json' });
response.end(JSON.stringify(result));
} catch {
response.writeHead(400, { 'content-type': 'text/plain' });
response.end('invalid example request');
}
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const url = `http://127.0.0.1:${server.address().port}/items`;
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ label: 'notebook', quantity: 2 }),
signal: AbortSignal.timeout(5000),
});
assert.equal(response.status, 201);
const result = await response.json();
assert.deepEqual(result, {
method: 'POST',
contentType: 'application/json',
received: { label: 'notebook', quantity: 2 },
});
console.log(`status: ${response.status}; method: ${result.method}`);
console.log(`received: ${result.received.label} x ${result.received.quantity}`);
} finally {
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections();
});
}Run it:
node post-local-json.mjsOutput:
status: 201; method: POST
received: notebook x 2The server reports the method, header, and parsed payload it actually received, so the assertions test the request rather than just the client-side object. Node’s fetch guide uses JSON.stringify with an explicit JSON content type for this pattern. The server only echoes the sample; it creates no item and writes nothing to disk.
Practical limits
These servers are disposable test fixtures, not production endpoints. Response text() and json() buffer the body; a time limit is not a response-size limit. Use bounded streaming for large or untrusted responses, and validate external JSON against an application schema. Consume or cancel response bodies rather than leaving them unread. The finally blocks stop accepting requests and then force-close fixture connections; a production server usually needs a graceful shutdown policy. Do not blindly retry a real POST, since it may already have caused a remote change.
Clean up
In the same terminal, remove only the three named source files, return to your starting directory, and remove the now-empty scratch directory:
rm -- fetch-local-json.mjs fetch-local-not-found.mjs post-local-json.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.