# Building status.live: ski lifts, roads, and webcams with visible data freshness

> Source: <https://dev.to/elvean/building-statuslive-ski-lifts-roads-and-webcams-with-visible-data-freshness-4jk6>
> Published: 2026-10-24 18:33:45+00:00

A ski lift can be running while the access road is restricted. A webcam can respond successfully while showing an old image. An empty lift board can mean the resort has stopped publishing its winter report.

These are different states, but a simple “open” or “closed” badge can hide the distinction.

I’m building [status.live](https://status.live/), a free website for checking ski lifts, road conditions, webcams, park alerts, campground opening schedules, and surf conditions. It also exposes a read-only JSON API and MCP tools. The ski catalog currently includes 284 resorts; available data and freshness vary by source.

Here are four decisions that have shaped the implementation, including a map mistake that made an otherwise valid build hard to use.

A successful HTTP response doesn’t necessarily contain a useful lift report. A parser can return an empty list because the resort has removed its seasonal board, changed its markup, or genuinely has nothing listed.

The data model needs to preserve that uncertainty. A simplified version looks like this:

```
type LiveSection<T> = {
  state: 'reported' | 'none_listed' | 'source_error' | 'loading';
  as_of: string | null;
  age_s: number | null;
  stale: boolean;
  items: T[];
  source: { name: string; url?: string };
};
```

This is an illustrative contract rather than a complete response schema. The important part is that an empty collection and a failed source read are distinguishable from a reported closure.

There are also two useful times to keep separate: when a source says an event happened, and when the application successfully checked it. Refreshing an HTML page should not make an old upstream observation look new.

When a source fails, a last-good snapshot can still be useful. It needs to retain its timestamp and carry a stale flag. Once that snapshot is too old to be useful, the interface should expose the missing data.

Fetching every source while rendering every page makes upstream traffic depend on how popular the site becomes.

For Colorado roads, the scheduled ingestion path is:

``` php
CDOT / COtrip statewide feeds
    -> scheduled ingestion
    -> shared snapshots
    -> resort pages, park pages, JSON API, MCP
```

The normal schedule reads three feeds—road conditions, incidents, and planned events—once every ten minutes. That is 432 scheduled upstream calls per day, rather than three calls for every visitor or resort page.

A shared cooldown handles HTTP 429 responses and respects Retry-After. Failed refreshes preserve last-good data with its original timestamp. Visitors read the stored snapshots rather than independently retrying the upstream service.

Other sources use caches suited to their update frequency and terms. The application runs on Cloudflare Workers, with KV for shared snapshots and D1 for catalog and monitoring records. A global cache matters here: a cache confined to one data center can still produce repeated upstream reads from different regions.

Lift status, roads, weather, and webcams have different sources and refresh cycles. A weather-service timeout should not erase an available lift report.

The page and API keep those sections separate. A section can be loading or unavailable while other sections remain useful. Each live section carries its own freshness information; one timestamp for the entire page would conceal those differences.

The distinctions also extend to what the data means:

The same principle applies to an AI assistant consuming the MCP tools. The assistant needs the source and uncertainty, not just a number it can confidently repeat.

The resort maps combine OpenStreetMap runs and lifts with elevation and, where available, reusable aerial imagery. The Swiss example below credits swisstopo, Copernicus EU-DEM, and OpenStreetMap contributors. It is a generated map, not the resort’s official piste map.

Gstaad exposed a usability problem. It spans separate ski sectors, and choosing orientation from the aggregate direction of the lifts put the main village toward the top. The terrain and lines rendered correctly, but the map was difficult to understand.

The revised 2D map places Gstaad village in the lower part, marks it explicitly, and labels the separate sectors. The build now checks the village’s vertical position when a village marker is configured.

*Gstaad’s three main mapped ski areas. Glacier 3000 and the peripheral lifts on its broader status board are outside this map; the resort page also links to the official map.*

That review has become part of adding each new resort: verify boundaries, compare lift names, check the village position, and inspect labels at the size readers will actually see. Some resort pages still have official-map links while their generated maps are being added.

For a concrete API example:

```
curl --fail --silent \
  'https://status.live/api/v1/ski/resorts/palisades?wait_ms=1500'
```

Inspect each section’s state and freshness before using its values. With a response time budget, slower sections may still be loading.

The [API reference](https://status.live/api) includes interactive examples, and the [source-health page](https://status.live/status) shows the monitoring side of the project. For AI clients, setup instructions are at [status.live/connect](https://status.live/connect).

The website requires no account. Personal and non-commercial API use is free; reuse and source-credit requirements are described in the [terms](https://status.live/terms).

If you build with live outdoor data, I’d be interested in how you present stale information without making people dig for it. Feedback on the resort pages—especially unclear states or map labels—is welcome too.
