Throughout this module, Node has been the server: somebody asked and we answered. Now we flip the role. A real backend almost never lives alone: it charges through a payment gateway, sends email with a provider, looks up exchange rates, validates addresses. In all those cases your server is another server's client.

And being a client has its own dangers, different from the ones you already know. The main one is that you depend on a machine you do not control: it can take thirty seconds, it can return a 500, it can be down exactly when your page has the most visitors. If your code does not prevent it, a third party's slowness becomes your platform's slowness.

Escena Viva wants to show prices in pounds as well, for the British audience. By the end you will have src/services/currency-exchange.js: a call to a public API with a timeout, retries, an in-memory cache with expiry and graceful degradation —if the provider fails, only euros are shown instead of breaking the page.

Contents

  1. Node as a client: fetch, http.request and third-party clients
  2. The GET request with fetch and the classic mistake
  3. Reading the response: json(), text() and the headers
  4. POST with headers and a JSON body
  5. Timeouts and cancellation with AbortController
  6. Retries: what is safe to repeat and what is not
  7. What lies underneath: http.request
  8. src/services/currency-exchange.js: caching and graceful degradation
  9. API keys and connection reuse

  1. Node as a client: fetch, http.request and third-party clients

Node has three ways of making an outgoing HTTP request, and choosing well is easy once you understand where each one comes from.

fetch (global) http.request Third parties (axios, undici, got)
Installation None, global since Node 18 None, a core module npm install (Module 5)
API Promises, just like in the browser Callbacks and streams Promises, with sugar
JSON body await response.json() Accumulate chunks by hand Automatic
HTTP errors Does not reject: you have to check ok Does not reject axios does reject on 4xx/5xx
Retries, agents By hand By hand Built in or optional
When to use it By default Understanding the fundamentals, fine stream control Projects with many integrations

The course's recommendation is straightforward: fetch by default. It is standard, it adds no dependencies, and the code you write works the same in the browser and in Node. undici deserves a special mention because it is the implementation underneath fetch in Node; using it directly gives you control over agents and connections, and we will glance at it in section 9.

  1. The GET request with fetch and the classic mistake

A basic call is two lines —const response = await fetch(url) and const data = await response.json()—, but this is the mistake everyone makes the first time:

// WRONG: it looks right and it isn't.
try {
  const data = await (await fetch('https://api.example.test/rates?base=EUR')).json();
  return data.rates.GBP;
} catch (error) {
  console.error('The call failed:', error);
}

fetch does not reject the promise when the server responds with a 404 or a 500. From its point of view, a 500 response is a success: the request travelled, the server answered, you have your response. That the content is an error is your business.

It only rejects when there has been no response: a network failure, DNS that does not resolve, a refused connection, an invalid TLS certificate, or explicit cancellation.

Situation Does fetch reject? What you get
200 OK No response.ok === true
404 Not Found No response.ok === false, status 404
500 Internal Server Error No response.ok === false, status 500
The server does not respond / does not exist Yes TypeError: fetch failed, with a cause
Timeout expired / cancelled Yes AbortError or TimeoutError

In the bad code above, a 500 that returns HTML makes response.json() throw a SyntaxError, and you end up debugging a JSON error when the real problem was something else. The check is mandatory:

const response = await fetch(url);

if (!response.ok) {
  const error = new Error(`The API responded ${response.status} ${response.statusText}`);
  error.appCode = 'EXTERNAL_SERVICE_DOWN';
  error.externalStatus = response.status;   // we will need it to decide whether to retry
  throw error;
}

Translating somebody else's failure into our domain vocabulary is what lets the rest of the system treat it like any other error: EXTERNAL_SERVICE_DOWN is already in the table from lesson 04-02 and maps to 503.

  1. Reading the response: json(), text() and the headers

The Response object has the status, the headers and the body, and the body is read only once: it is a stream, and consuming it exhausts it.

response.status;                        // 200
response.ok;                            // true if status is between 200 and 299
response.headers.get('content-type');   // 'application/json' (case-insensitive)
await response.json();                  // parses the body as JSON
await response.text();                  // the body as text
await response.arrayBuffer();           // binary: a poster, a PDF

Trying to read it twice throws TypeError: Body is unusable. If you need the text and the JSON —very useful for producing a decent error message—, read the text once and parse it yourself:

const text = await response.text();

if (!response.ok) {
  // An error body usually carries a clue; log it truncated.
  console.error(`[currency] ${response.status}: ${text.slice(0, 200)}`);
  throw domainError('EXTERNAL_SERVICE_DOWN', `The API responded ${response.status}`);
}

const data = JSON.parse(text);

response.headers is a Headers object, not a dictionary: you query it with .get() and it is case-insensitive. Two headers worth watching in third-party APIs are retry-after (the seconds it asks you to wait after a 429) and the rate-limit ones, usually called x-ratelimit-remaining.

  1. POST with headers and a JSON body

Sending data is fetch's second argument:

const response = await fetch('https://api.payments.test/charges', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.PAYMENTS_KEY}`,   // never in the code
    Accept: 'application/json'
  },
  // The body is sent as a STRING: you have to serialize it yourself.
  body: JSON.stringify({ orderId: 'ord-000042', amountCents: 5000, currency: 'EUR' })
});

The two usual omissions go together: fetch does not serialize for you (passing an object in body ends up sending the string [object Object]) and it does not set the JSON Content-Type on its own, so the server receives something it cannot interpret and responds 400. If the body is a form, body: new URLSearchParams({...}) does set the right type automatically.

  1. Timeouts and cancellation with AbortController

This section is the most important of the lesson. fetch has no timeout by default. If the API on the other side accepts the connection and then goes off to think, your await waits. And waits. With the system's default, that could be minutes.

Now add up the effect: every user request to your server fires an external call that takes two minutes. Requests pile up, sockets run out and your platform goes down because of a third party. This is not a theoretical case: it is the most common way a healthy backend degrades.

The solution is an AbortSignal. Since Node 17.3 there is a shortcut for the usual case: fetch(url, { signal: AbortSignal.timeout(3000) }) cancels the request after those milliseconds.

When it fires, fetch rejects with an error whose name is 'TimeoutError'. If you need to cancel on your own —because the user closed the connection, for instance (lesson 04-05)—, use the full controller:

const controller = new AbortController();

// If the client who asked us for the page leaves, we cancel the external call.
req.on('close', () => controller.abort());

const response = await fetch(url, { signal: controller.signal });

And if you need both, AbortSignal.any([...]) combines signals: it cancels with whichever happens first.

try {
  const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
  // ...
} catch (error) {
  if (error.name === 'AbortError') return;   // we cancelled it: nobody is listening
  if (error.name === 'TimeoutError') {
    throw domainError('EXTERNAL_SERVICE_DOWN', 'The currency API did not respond in time');
  }
  throw error;
}

The rule, with no exceptions: every outgoing call carries a timeout. Three seconds is a reasonable starting point for a data API; if the provider needs more, that is a conscious decision, not an oversight.

  1. Retries: what is safe to repeat and what is not

A network failure can be transient. Retrying makes sense... for some things. We reuse retry from lesson 02-04, with its exponential backoff, and give it the right criteria.

The first thing is the method. Idempotent methods (lesson 04-05) can be repeated with no consequences; POST cannot:

Method Retry? Risk if you do
GET, HEAD Yes, always None: you only read
PUT, DELETE Yes None: the final state is the same
POST Only with an idempotency key Charging twice, creating two orders

The second thing is the cause. Not every failure improves with waiting:

Response Retry? Why
Network failure, DNS, ECONNRESET Yes Usually transient
408, 429 Yes, respecting Retry-After The server explicitly asks you to wait
500, 502, 503, 504 Yes A problem on the other side, often temporary
400, 401, 403, 404, 422 No Your request is wrong: repeating it gives the same
// Retry only what can improve by waiting.
function isRetryable(error) {
  if (error.name === 'TimeoutError') return true;
  if (error.appCode !== 'EXTERNAL_SERVICE_DOWN') return true;   // network failure
  const status = error.externalStatus;
  return status === 408 || status === 429 || (status >= 500 && status <= 599);
}

const data = await retry(() => fetchRates(), {
  attempts: 3,
  initialDelayMs: 250,   // 250, 500, 1000 ms
  factor: 2,
  isRetryable
});

Retrying a 401 is throwing away time and tripling the load on a service that has already told you your key is wrong. And there is a bigger danger: if your API goes down and all your clients retry at once, the stampede prevents it from recovering. That is why retries come with exponential backoff and, in large systems, with a bit of randomness (jitter) so they do not line up.

  1. What lies underneath: http.request

fetch is convenient, but it is worth seeing the mechanism at least once. http.request (and https.request) is the original API: it returns a writable stream for the request body and hands you a readable stream with the response.

const https = require('node:https');

function fetchJson(url) {
  return new Promise((resolve, reject) => {
    const request = https.request(url, { method: 'GET', timeout: 3000 }, (response) => {
      // response is an IncomingMessage: the SAME object a server receives.
      const chunks = [];
      response.on('data', (chunk) => chunks.push(chunk));
      response.on('end', () => {
        // Accumulate Buffers and decode at the end: lesson 04-05.
        const text = Buffer.concat(chunks).toString('utf8');
        try { resolve({ status: response.statusCode, data: JSON.parse(text) }); }
        catch (error) { reject(error); }
      });
    });

    request.on('timeout', () => request.destroy(new Error('Timeout expired')));
    request.on('error', reject);
    request.end();   // MANDATORY: without end() the request is not sent
  });
}

What is revealing is the symmetry: the IncomingMessage you receive as a client is of the same class as the req your server receives, and the request you send is written just like a response. Client and server are the same mechanism seen from both sides.

You can also see what fetch saves you: promises, body accumulation, parsing, redirects, decompression. Use http.request when you need real stream control —downloading an enormous file straight to disk with pipeline, without going through memory— or when you work with an API that demands something very specific from the socket.

  1. src/services/currency-exchange.js: caching and graceful degradation

All of it together, in Escena Viva's real case. Two requirements govern the design:

  • Do not call the external API on every request. Exchange rates barely move; looking them up a thousand times a minute is absurd, slow and will probably earn you a 429. They are cached in memory with an expiry.
  • Do not break the page if the provider fails. Listings without prices in pounds are still useful; listings with a 503 are useless. This is called graceful degradation.
// src/services/currency-exchange.js
// Looks up exchange rates in an external API, with an in-memory cache,
// a timeout, retries and graceful degradation.

const { retry } = require('../utils/retry.js');

const BASE_URL = process.env.CURRENCY_API_URL ?? 'https://api.example.test/rates';
const TIMEOUT_MS = 3000;
const CACHE_TTL_MS = 60 * 60 * 1000;   // 1 hour: rates barely move

// Process cache: { GBP: { rate, expiresAt } }. It is lost on restart,
// which is acceptable here. Across several processes we would need Redis (M10).
const cache = new Map();

async function fetchRate(currency) {
  const url = `${BASE_URL}?base=EUR&target=${encodeURIComponent(currency)}`;
  const response = await fetch(url, {
    signal: AbortSignal.timeout(TIMEOUT_MS),
    headers: { Accept: 'application/json' }
  });

  if (!response.ok) {
    const failure = domainError('EXTERNAL_SERVICE_DOWN', `The currency API responded ${response.status}`);
    failure.externalStatus = response.status;
    throw failure;
  }

  const data = await response.json();
  const rate = Number(data?.rates?.[currency]);

  // Never trust the shape of somebody else's response: validate it.
  if (!Number.isFinite(rate) || rate <= 0) {
    throw domainError('EXTERNAL_SERVICE_DOWN', `Invalid exchange rate for ${currency}`);
  }
  return rate;
}

// Returns the EUR -> currency rate, using the cache if it is still valid.
async function getExchangeRate(currency) {
  const cached = cache.get(currency);
  if (cached && cached.expiresAt > Date.now()) return cached.rate;

  const rate = await retry(() => fetchRate(currency), {
    attempts: 3,
    initialDelayMs: 250,
    isRetryable: (error) => error.name === 'TimeoutError' ||
      [408, 429].includes(error.externalStatus) || error.externalStatus >= 500
  });

  cache.set(currency, { rate, expiresAt: Date.now() + CACHE_TTL_MS });
  return rate;
}

// Converts euro cents into cents of another currency. All integers.
function convertCents(euroCents, rate) {
  return Math.round(euroCents * rate);
}

// GRACEFUL DEGRADATION: if the service fails, only euros are returned
// and the reason is noted. The page keeps working.
async function sessionPrices(session, currency = 'GBP') {
  const prices = { EUR: session.priceCents };
  try {
    prices[currency] = convertCents(session.priceCents, await getExchangeRate(currency));
  } catch (error) {
    console.error(`[currency] no conversion to ${currency}: ${error.message}`);
    prices.notice = `Price in ${currency} temporarily unavailable`;
  }
  return prices;
}

module.exports = { getExchangeRate, convertCents, sessionPrices, cache };

Four decisions worth underlining:

  • The try/catch is in sessionPrices, not in getExchangeRate. The low-level function throws —which is right: it cannot decide on its own what is acceptable—. The one who knows the context, and therefore knows we can live without pounds, is the one who catches.
  • The other party's response is validated. The API returning 200 does not guarantee the body has the expected shape. One extra Number.isFinite saves you a NaN propagating all the way into a price.
  • Money stays integer. Math.round over cents: 2500 cents at a rate of 0.84 is 2100 cents, never 21.0000000003.
  • The cache is per process. With several processes (cluster, Module 10) each would have its own, which is acceptable for exchange rates and would not be for stateful data. That is where Redis comes in, in lesson 10-03.

A check with a made-up provider that does not exist:

CURRENCY_API_URL=https://does-not-exist.test/rates node -e "
  const { sessionPrices } = require('./src/services/currency-exchange.js');
  sessionPrices({ priceCents: 2500 }).then((p) => console.log(JSON.stringify(p, null, 2)));
"
# {"EUR": 2500, "notice": "Price in GBP temporarily unavailable"}

The external service is dead and the response is still useful. That is degrading well.

  1. API keys and connection reuse

An API key is never written in the code. Not "temporarily", not "just for testing". The code ends up in a repository, the repository ends up shared, and there are bots crawling GitHub looking for exactly that. Keys are read from the environment:

const API_KEY = process.env.CURRENCY_API_KEY;

// Fail at STARTUP, not on some user's first request.
if (!API_KEY) throw new Error('The CURRENCY_API_KEY environment variable is missing');

Checking the configuration at startup rather than on the first call is an important difference: you would rather the deployment fail immediately than fail silently on Tuesday afternoon. Full configuration handling —.env, secrets, environments— is lesson 11-01.

About connection reuse: opening an HTTPS connection is expensive. You need the TCP handshake (one round trip) and the TLS negotiation (two more). With an API 80 ms away, that is around 240 ms before sending a single useful byte. If you make a thousand calls and open a thousand connections, you throw away four minutes on handshakes.

The solution is keep-alive: keeping the connection open and reusing it. Node does it for you in two places:

  • fetch uses undici underneath, which keeps a connection pool with keep-alive enabled by default. You have to do nothing.
  • http.request uses http.globalAgent, which since Node 19 also ships keepAlive: true. In earlier versions you had to create the agent by hand:
// An agent with keep-alive, shared by every call to the same service.
const agent = new https.Agent({ keepAlive: true, maxSockets: 50 });
https.request(url, { agent }, handleResponse);

What you must avoid is the opposite: creating a new agent per request, which cancels out the whole benefit. And if you need fine control over the pool —connection limits per destination, lifetimes—, undici exposes its Agent directly. We will come back to this when measuring performance in lesson 10-04.

Common Mistakes and Tips

  • Assuming fetch rejects on a 404 or a 500. It does not. Check response.ok always, and translate the failure into your domain vocabulary.
  • Calling with no timeout. It is the fastest route to a slow third party taking down your server. AbortSignal.timeout(3000) on every outgoing call.
  • Passing an object in body without JSON.stringify. You send [object Object] and get a baffling 400. And remember the Content-Type.
  • Reading the body twice. TypeError: Body is unusable. Read text() once and parse it yourself if you need both.
  • Retrying a POST with no idempotency key, or retrying a 400/401. The first duplicates charges; the second is useless load.
  • Caching with no expiry, or caching error responses too. The first leaves you with stale data forever; the second turns a one-off failure into an hour-long one.
  • Putting API keys in the code. Into the environment, and checked at startup.
  • Tip: validate the shape of what an external API returns before using it —a 200 guarantees nothing— and always decide, explicitly, what happens if the external service fails. Degrading is usually better than propagating the error, but it is a business decision, not a technical one.

Exercises

Exercise 1: proving that fetch does not reject

With the Escena Viva server running, write src/lab/test-fetch.js that requests /events (200), /events/evt-999 (404), /does-not-exist (404) and http://localhost:9999/ (no server), all inside the same try/catch. Print for each one whether the promise resolved or rejected, the ok, the status and the error.name. Explain which is the only one that lands in the catch and why.

Exercise 2: measuring the caching effect

Add to currency-exchange.js a counter of real API calls and expose GET /currency/stats with { calls, cacheHits, cacheSize }. Fire 50 requests in a row at a route that uses sessionPrices and check how many reach the provider. Then lower CACHE_TTL_MS to 100 ms, repeat and explain the difference.

Exercise 3: timeout and degradation

Set up a test server on port 4000 whose only route waits 5 seconds before responding (using sleep). Point CURRENCY_API_URL at it and check that sessionPrices returns the euro price with its notice after about 3 seconds, not 5. Measure the real time with console.time and explain why it is not exactly 3000 ms when retries are enabled.

Solutions

Solution 1. Only the last one lands in the catch: it is the only one where there was no response.

const urls = ['http://localhost:3000/events', 'http://localhost:3000/events/evt-999',
  'http://localhost:3000/does-not-exist', 'http://localhost:9999/'];

for (const url of urls) {
  try {
    const response = await fetch(url);
    console.log(`${url.padEnd(40)} | resolved | ok=${response.ok} | status=${response.status}`);
  } catch (error) {
    console.log(`${url.padEnd(40)} | REJECTED | ${error.name}: ${error.cause?.code ?? error.message}`);
  }
}

Both 404s resolve with ok=false: as far as fetch is concerned, the request succeeded. localhost:9999 rejects with TypeError: fetch failed and a cause.code of ECONNREFUSED, because nobody is listening. That is exactly why if (!response.ok) is not optional.

Solution 2. With the one-hour cache, the 50 requests produce a single real call: the first one fetches it and the remaining 49 find it still valid.

const stats = { calls: 0, cacheHits: 0 };

const cached = cache.get(currency);
if (cached && cached.expiresAt > Date.now()) {
  stats.cacheHits++;
  return cached.rate;
}
stats.calls++;   // ...and the rest of getExchangeRate, unchanged

With CACHE_TTL_MS = 100, the calls shoot up because almost every request finds the entry expired. That is where you see that the expiry value is an explicit balance between freshness and cost: for an exchange rate, an hour is plenty; for a session's capacity, caching at all would be a mistake.

Solution 3. The total time is not 3000 ms but roughly 3000 + 250 + 3000 + 500 + 3000 ≈ 9.75 s if there are three attempts, because each attempt has its own timeout and there is a wait between them. That is the effect to keep in mind when choosing the numbers: the worst case of a call with retries is the sum of all of them.

// The slow test server
require('node:http').createServer(async (req, res) => {
  await sleep(5000);
  res.end(JSON.stringify({ rates: { GBP: 0.84 } }));
}).listen(4000);

The euro price arrives anyway. If the requirement is "never more than 3 seconds in total", the timeout cannot live only inside each attempt: you have to wrap the whole thing with a global AbortSignal.timeout, or reduce the attempts. That a retry is not free is precisely why isRetryable has to be restrictive.

Conclusion

Escena Viva now talks to the world in both directions. As a client, your default tool is fetch, global and standard since Node 18, with http.request underneath for when you need stream control and third-party clients when the project piles up integrations. And the first thing to burn into memory is that fetch does not reject on a 404 or a 500: it only rejects when there was no response. Checking response.ok and translating the failure into EXTERNAL_SERVICE_DOWN —which the 04-02 table turns into a 503— is mandatory, as is reading the body only once and validating the shape of what arrives.

You know how to send a POST with its explicit JSON.stringify and its Content-Type, and above all you know that every outgoing call carries a timeout: AbortSignal.timeout(3000), or a full AbortController when the one leaving is your own client. Without that, a third party's slowness becomes your platform's outage, the most common and most avoidable form of degradation. Retries with exponential backoff reuse retry from Module 2 with a double criterion: only idempotent methods —POST solely with an idempotency key— and only causes that can improve by waiting: network, 408, 429 and 5xx, never a 400 or a 401.

And you have the complete service: src/services/currency-exchange.js, with an in-memory cache with expiry so you do not call on every request —a balancing decision between freshness and cost, which across several processes will call for Redis (10-03)—, money conversion in whole cents with Math.round, and graceful degradation: if the provider goes down, the listings show euros and a notice instead of an error. API keys live in process.env and are checked at startup, and connections are reused with keep-alive, which fetch gives you for free thanks to undici.

With this you close Module 4. Escena Viva has gone from being a terminal program to being a web platform: a node:http server that is an EventEmitter with robust startup and graceful shutdown, consistent responses with their status table and their translation from error.appCode to HTTP, a router of your own with compiled patterns and centralized error handling, a front-end served with streams, conditional caching and a path hardened against traversal, a POST /orders that validates and really sells, and an HTTP client resilient to other people's failures. All of it without a single external dependency: only Node's core.

That "no dependencies" has been deliberate, and it has also had a price you paid by hand: routing, body parsing, MIME types, caching. In Module 5: NPM and Package Management we take the next step and learn to lean on other people's work with judgment: what package.json is, how dependencies are installed and versioned, what ^1.2.3 really means, what package-lock.json is for, how to automate the project with scripts and how to assess the security of what you bring home. It is the indispensable step before, in Module 6, Express replaces your router and you recognize in every one of its pieces something you have already written yourself.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved