Weather, crypto prices, GitHub stats — plenty of useful data lives behind public APIs. But calling them on every page load is slow and can get you rate-limited or blocked. This guide builds a small aggregator: it fetches an upstream API once, caches the result for a while, and serves it as your own fast JSON endpoint. It runs on the Node.js application and uses fetch (built into Node) plus a tiny time-to-live cache.
| At a glance | |
|---|---|
| You need | A server running the Node.js application |
| Build with | Express + Node's built-in fetch + a TTL cache |
| Plan | Any — free runs while your session timer has time, premium runs 24/7 |
| Time | About twenty minutes |
New to Node here? Node.js on Falix covers the entry file, auto-install, and SERVER_PORT.
The idea
browser → your /api/data → (cache fresh?) → yes → serve cached copy
→ no → fetch upstream, store, serve
The cache is the whole point. Set a TTL (time to live) of, say, 60 seconds and the upstream is called at most once a minute no matter how many visitors you have. Fewer calls means faster responses and no rate-limit trouble.
Step 1 — Create the project files
package.json:
{
"name": "api-aggregator",
"version": "1.0.0",
"private": true,
"dependencies": {
"express": "^4.19.2"
}
}
index.js — the whole aggregator:
const express = require('express');
const path = require('node:path');
const app = express();
app.use(express.static(path.join(__dirname, 'public')));
// The upstream API we aggregate. This one needs no auth key.
// Swap in any public JSON API; if it needs a key, read it from process.env.
const UPSTREAM = process.env.UPSTREAM_URL || 'https://api.coindesk.com/v1/bpi/currentprice.json';
// --- tiny cache: value + expiry timestamp, keyed by request ---
const cache = new Map();
const TTL_MS = 60 * 1000; // 60 seconds
async function getCached(key, fetcher) {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) {
return { data: hit.data, cached: true };
}
const data = await fetcher();
cache.set(key, { data, expires: Date.now() + TTL_MS });
return { data, cached: false };
}
app.get('/api/data', async (req, res) => {
try {
const { data, cached } = await getCached('data', async () => {
const r = await fetch(UPSTREAM);
if (!r.ok) throw new Error(`upstream ${r.status}`);
return r.json();
});
res.set('X-Cache', cached ? 'HIT' : 'MISS');
res.json(data);
} catch (err) {
console.error('aggregate failed:', err.message);
res.status(502).json({ error: 'Upstream unavailable.' });
}
});
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
Step 2 — Start it
Press Start. npm install runs (Express only — fetch is built into Node 18+), then Listening on port …. Open your address and hit /api/data.
The response carries an X-Cache header: MISS the first time (it went upstream) and HIT for the next 60 seconds (served from memory). Refresh a few times and watch it flip.
🎯 Good to know about the verification: this build's caching was checked against a local mock API running beside it — the mock counted its own hits, which proved the upstream is called on the first request and not called again while the cache is fresh (even when the mock was stopped, the cached copy still served). The
fetchcall itself is standard; pointUPSTREAMat any real public JSON API and it behaves the same way.
How the cache works
cache is a plain Map. Each entry stores { data, expires }:
| Step | What happens |
|---|---|
| Request comes in | Look up the key in the map |
Entry exists and expires > now |
Return it, mark cached: true — no network call |
| Missing or expired | Call fetcher(), store { data, expires: now + TTL }, return cached: false |
That's a complete TTL cache in a dozen lines. No library needed.
💡 Tip: Cache keys matter. This example uses one fixed key (
'data') because it serves one upstream. If you aggregate per city or per user, build the key from the request —getCached(\weather:${city}`, ...)` — so each variant caches separately.
Working with a real upstream
- No key needed? Just set
UPSTREAMto the URL. Many public APIs (open data, some crypto and weather feeds) work with no auth. - Key needed? Never hard-code it. Put it in
.env(API_KEY=...), load it withdotenv, and add it to the request — as a header or query string per the API's docs. See Environment variables & secrets. - Combining several APIs? Fetch them together with
await Promise.all([...])inside the fetcher and return one merged object. The cache stores the merged result, so all of them refresh on the same TTL.
Extend it
- A dashboard front end: put an
index.htmlinpublic/that fetches/api/dataand renders it — your server becomes both the page and its API, like the Express website + API build. - A refresh endpoint: add a route that does
cache.delete('data')so you can force a fresh pull on demand. - Longer TTLs for slow-changing data: bump
TTL_MSto minutes or hours for things that rarely change.
Troubleshooting
- HTTP 502 "Upstream unavailable" — the upstream returned an error or was unreachable. The console logs the exact status; check the API is up and the URL is right.
X-Cachealways says MISS — your TTL is too short, or you're restarting between requests (an in-memory cache resets on restart — that's expected).- Rate-limited by the upstream anyway — your TTL is too low for how busy you are, or you're caching per-request with keys that never repeat. Raise the TTL or coarsen the key.
- Page won't load — the
listencall must useSERVER_PORTand0.0.0.0. See I can't reach my app.
⚠️ Heads up: This cache lives in memory, so it clears on every restart and isn't shared across instances. For a cache that survives restarts or is shared, use Redis caching — the same TTL idea, backed by a real store.