Build an API dashboard with caching

Fetch a public API, cache the result with a TTL map so you don't hammer the upstream, and serve it as your own JSON endpoint — the full Node.js build with run steps.

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 fetch call itself is standard; point UPSTREAM at 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 UPSTREAM to 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 with dotenv, 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.html in public/ that fetches /api/data and 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_MS to 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-Cache always 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 listen call must use SERVER_PORT and 0.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.


Next steps

Was this guide helpful?