How to Fetch Data From an API in Express (Node.js fetch Guide)
On this page
- A first API call from an Express route
- fetch doesn’t throw on HTTP errors
- Add a timeout
- Query strings, POST requests and API keys
- Call several APIs in parallel
- Cache responses that rarely change
- Should I still use Axios?
- Retrying failed requests safely
- Wrap each external API in its own module
- Validate what comes back
- Streaming large responses
- Logging calls to external services
- Handling API keys and quotas responsibly
- Frequently asked questions
- Oh hi there 👋It’s nice to meet you.
- Sign up to receive awesome content in your inbox.
Your Express server often needs data from somewhere else: weather, payments, a CRM, or another one of your own services. Calling the API from the server, instead of directly from React, keeps your API keys secret, avoids CORS problems, and lets you cache and reshape the data before it reaches the browser.
Since Node.js 18, fetch is built in, so you no longer need axios or node-fetch for most calls.
A first API call from an Express route
const express = require('express');
const app = express();
app.get('/api/github/:user', async (req, res) => {
const response = await fetch(`https://api.github.com/users/${encodeURIComponent(req.params.user)}`, {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'mernstackdev-demo' },
});
if (!response.ok) {
return res.status(response.status === 404 ? 404 : 502).json({ message: 'Could not load that user' });
}
const data = await response.json();
res.json({ name: data.name, repos: data.public_repos, followers: data.followers });
});
app.listen(5000);
Two habits from this example are worth keeping. First, encodeURIComponent stops users from injecting extra path segments into the URL. Second, the route returns only the fields the frontend needs instead of passing the whole upstream response through.
fetch doesn’t throw on HTTP errors
This catches almost everyone. fetch only rejects when the request can’t be made at all, such as a DNS failure or a dropped connection. A 404 or 500 response is a “successful” fetch, so you must check response.ok or response.status yourself, as shown above.
Add a timeout
Without a timeout, a slow upstream API can keep your request hanging for a long time and tie up resources. Use AbortSignal.timeout():
async function getJson(url, options = {}) {
const response = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
if (!response.ok) {
const error = new Error(`Upstream responded ${response.status}`);
error.status = 502;
throw error;
}
return response.json();
}
app.get('/api/weather', async (req, res, next) => {
try {
const data = await getJson(`https://api.example.com/weather?city=${encodeURIComponent(req.query.city || 'Pune')}`);
res.json(data);
} catch (err) {
if (err.name === 'TimeoutError') return res.status(504).json({ message: 'Weather service is slow, try again' });
next(err);
}
});
Query strings, POST requests and API keys
// Build query strings safely
const params = new URLSearchParams({ q: 'mern stack', page: '2' });
const results = await getJson(`https://api.example.com/search?${params}`);
// Send JSON with an API key from your .env file
const created = await getJson('https://api.example.com/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.PAYMENTS_API_KEY}`,
},
body: JSON.stringify({ amount: 49900, currency: 'INR' }),
});
Keep keys in environment variables on the server only. Anything you put in React code, even through a VITE_ variable, ends up in the browser for anyone to read.
Call several APIs in parallel
app.get('/api/dashboard', async (req, res, next) => {
try {
const [user, orders, stats] = await Promise.all([
getJson('https://api.example.com/me'),
getJson('https://api.example.com/orders?limit=5'),
getJson('https://api.example.com/stats'),
]);
res.json({ user, orders, stats });
} catch (err) {
next(err);
}
});
Three calls of 300 ms each finish in about 300 ms together instead of 900 ms one after another. Use Promise.allSettled() if the page can still render when one of them fails.
Cache responses that rarely change
const cache = new Map();
async function cachedJson(url, ttlMs = 60_000) {
const hit = cache.get(url);
if (hit && hit.expires > Date.now()) return hit.data;
const data = await getJson(url);
cache.set(url, { data, expires: Date.now() + ttlMs });
return data;
}
An in-memory cache works for a single server. With several servers, use Redis so they share the cache. Caching also keeps you under the upstream API’s rate limits.
Should I still use Axios?
Built-in fetch covers most server-side needs. Axios is still handy if you want interceptors, automatic JSON handling, or the same client code in older environments. Either choice is fine; just be consistent across your project.
Retrying failed requests safely
Network calls fail occasionally: a timeout, a dropped connection, a 503 while the other service restarts. Retrying once or twice often succeeds, but retries must be done carefully, or they make an outage worse by flooding the struggling service.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchWithRetry(url, options = {}, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
if (res.status < 500 && res.status !== 429) return res; // success or a client error: don't retry
if (i === attempts) return res;
const retryAfter = Number(res.headers.get('Retry-After')) * 1000;
await sleep(retryAfter || 300 * 2 ** i + Math.random() * 200);
} catch (err) {
if (i === attempts) throw err;
await sleep(300 * 2 ** i + Math.random() * 200);
}
}
}
Three rules are built into this helper. Retry only on server errors, rate limits and network failures, never on 400 or 404, because those won’t fix themselves. Wait longer after each attempt (exponential backoff) and add a little randomness, so many servers don’t retry at exactly the same moment. And only retry requests that are safe to repeat: GET requests, or POST requests protected by an idempotency key.
Wrap each external API in its own module
Scattering fetch calls to the same service across many routes makes changes painful. Put each external API in one small module with clear functions, and let routes call those:
// services/weather.js
const BASE = 'https://api.example.com';
async function getCurrent(city) {
const params = new URLSearchParams({ city, units: 'metric' });
const res = await fetchWithRetry(`${BASE}/current?${params}`, {
headers: { Authorization: `Bearer ${process.env.WEATHER_API_KEY}` },
});
if (!res.ok) throw Object.assign(new Error('Weather service failed'), { status: 502 });
const data = await res.json();
return { city: data.name, tempC: data.main.temp, summary: data.weather[0].description };
}
module.exports = { getCurrent };
// routes/weather.js
router.get('/:city', async (req, res) => res.json(await weather.getCurrent(req.params.city)));
Now the API key, URL, retries and response mapping live in one place. If the provider changes its response format, or you switch providers, you change one file. It also makes testing easy, because you can replace the module with a fake in tests.
Validate what comes back
Third-party APIs change, have bugs, and sometimes return unexpected shapes. If your code assumes data.main.temp exists and it doesn’t, you get a confusing crash deep in your app. Checking the response at the boundary turns that into a clear error:
const { z } = require('zod');
const WeatherResponse = z.object({
name: z.string(),
main: z.object({ temp: z.number() }),
weather: z.array(z.object({ description: z.string() })).min(1),
});
const data = WeatherResponse.parse(await res.json()); // throws with a clear message if the shape changed
Paste a sample response into the JSON to Zod tool to generate a schema like this in seconds, or into the JSON to TypeScript tool if you only need types.
Streaming large responses
Some APIs return large files or long streams, such as exports, media or AI responses that arrive token by token. Instead of waiting for the whole body, you can pass it straight through to your client:
const { Readable } = require('node:stream');
const { pipeline } = require('node:stream/promises');
app.get('/api/reports/:id/download', async (req, res) => {
const upstream = await fetch(`https://reports.example.com/files/${encodeURIComponent(req.params.id)}`, {
headers: { Authorization: `Bearer ${process.env.REPORTS_API_KEY}` },
});
if (!upstream.ok || !upstream.body) return res.status(502).json({ message: 'Report unavailable' });
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream');
await pipeline(Readable.fromWeb(upstream.body), res);
});
fetch returns a web stream, and Readable.fromWeb() converts it to a Node.js stream that pipeline can send to the response. Memory stays low no matter how large the file is. Streams are explained in detail in Node.js streams: a practical guide.
Logging calls to external services
When something goes wrong, the first question is usually “was it us or them?”. Log every external call with the service name, the status code and how long it took, but never the API key or personal data:
const started = Date.now();
const res = await fetch(url, options);
console.info(JSON.stringify({ service: 'weather', status: res.status, ms: Date.now() - started }));
A few days of these logs show you which providers are slow or unreliable, and they make support conversations with providers much easier.
Handling API keys and quotas responsibly
Most paid APIs bill per request or limit how many calls you can make per minute. A few habits protect both your budget and your users:
- Keep keys on the server only, in environment variables, and give each environment its own key so a leaked development key can be revoked without breaking production.
- Restrict keys in the provider’s dashboard where possible, for example by IP address or allowed API, so a stolen key is less useful.
- Cache and deduplicate. If a hundred users load the same dashboard within a minute, one upstream call should serve all of them.
- Rate-limit your own endpoint that wraps the paid API, so one user or bot can’t burn through your quota.
- Set a billing alert with the provider, so an unexpected spike in usage reaches you as an email rather than as a surprise invoice.
Frequently asked questions
Why call the API from Express instead of React?
To keep API keys secret, avoid CORS errors, cache results, and send the browser only the data it needs.
Which Node.js version has fetch built in?
Node.js 18 and newer. On older versions, upgrade Node or install a package such as undici or axios.
How do I handle a 429 rate limit response?
Read the Retry-After header if present, wait that long, and retry a limited number of times. Caching responses reduces how often you hit the limit.
How do I test code that calls external APIs?
Replace the service module with a fake in unit tests, or intercept requests with a tool such as nock or MSW so tests never hit the real API. Keep one small set of optional tests that do call the real service, to catch breaking changes on their side.




