← All case studies

SKYABOVE

A dashboard of live air traffic, built on a third party API I do not control and cannot afford to hammer. Most of the interesting work is not the UI, it is everything that keeps the page useful when the API is slow, rate limited, or down.

Role
Solo project
Year
2026
Stack
Next.js, TypeScript, REST API, Caching, Rate Limiting, GSAP
Screenshot of the SKYABOVE interface

The idea

I wanted to build something that ran on real data instead of numbers I made up, because a project stops being an exercise the moment something outside your control can break it. Anything with a live API behind it will have a bad day eventually, and that is the part I actually wanted to learn: not how to fetch data, which is easy, but what the page should do when fetching fails.

The problem

AviationStack needs an API key. The moment you call it from the browser, that key is in the network tab and anyone can spend your quota. So the request has to happen on the server.

That solves the key but creates the real constraint: a free plan gives you a small number of calls, and every visitor refreshing the page burns them. One person with the dev console open can exhaust a month of quota in an afternoon. The dashboard had to work for visitors without letting visitors destroy it.

The decisions

  • Proxy through a route handler

    app/api/flights/route.ts calls AviationStack server side and returns only the computed stats. The key lives in an environment variable and never reaches the client. The browser talks to my API, not theirs.

  • Cache in memory for 30 minutes

    Flight aggregates do not change meaningfully minute to minute, so half an hour of staleness costs nothing and cuts upstream calls by orders of magnitude. I used a module level variable rather than Redis because the app runs as one instance, and adding infrastructure for a single cached object would have been ceremony, not engineering.

  • Rate limit per IP

    Twenty requests a minute per IP, tracked in a Map, with old timestamps swept every five minutes so it does not grow forever. Over the limit gets a 429 with a Retry-After header rather than a silent failure, because a client that knows when to come back is better than one that keeps retrying.

  • Serve stale data when upstream dies

    This is the part I am most pleased with. If AviationStack fails and I have anything cached, the endpoint returns that instead of an error, flagged with stale: true so the client knows. A slightly old dashboard is far more useful than an error page. Only when there is nothing cached at all does it return a 502.

Types as a contract

The AviationStack response is fully typed, down to the nullable fields: delay can be null, the live block can be null, flight_status is a union of six specific strings. Writing those out was tedious and it is the reason the transform code has no defensive guesswork in it. The compiler knows which fields can go missing, so I do not have to remember.

What’s next

The cache lives in memory on a single instance, which is fine now and would fall apart the moment it ran on two, so moving it somewhere shared is the first thing I would do if this ever had real traffic. I also want to persist it, so a cold start does not begin with nothing and leave the first visitor with no fallback if the API happens to be having a bad day.

Beyond that I want to do more with the data I am already fetching. There is enough in the response to show delays and routes properly, and right now I am only using a fraction of it.