What is Tempest
Tempest is a Current Conditions weather API that runs 100% on Cloudflare, with zero origin calls. It copies AccuWeather's API one to one: the same two calls an AccuWeather client makes (a geoposition search that turns a latitude and longitude into a location key, then current conditions for that key), the same parameters, and the same JSON. The answer is AccuWeather's 32 current-conditions fields in AccuWeather's order, every measurement in both Metric and Imperial, with nothing added. Where the call came from and which Durable Object answered travel in X-Tempest-* response headers, so an existing client can point at Tempest unchanged.
- 100% on Cloudflare. Workers answer the request, Durable Objects hold the whole US dataset in two regional instances, R2 keeps the files, Queues fan out the processing, and the Cache API keeps place names.
- Zero origin calls. No current-conditions request ever reaches an origin server. The only inbound data movement is one scheduled pull of each new NOAA analysis, the same whether one person asks or a billion do. (The location search asks the public US Census geocoder for a point's place name and ZIP code once, then keeps the answer for a week.)
- Served from the user's side of the country. The instance that answers is chosen by where the caller is, not by where the weather is.
Architecture
The two calls
An AccuWeather client makes two plain HTTPS GETs, and Tempest answers both from the Worker in the data center the request entered; there is no origin behind them to proxy to, cache from, or fail over to. The apikey is Tempest's own key (a Cloudflare Worker secret, checked in constant time), not an AccuWeather key, and nothing in the request path calls AccuWeather.
1. Location search: latitude and longitude to a location key
GET https://tempest.goalbert.ai/locations/v1/cities/geoposition/search
?apikey=KEY&q=40.7128,-74.0060&language=en-usThe answer is AccuWeather's location object with the same fields in the same order (Version, Key, Type, Rank, LocalizedName, EnglishName, PrimaryPostalCode, Region, Country, AdministrativeArea, TimeZone, GeoPosition, IsAlias, SupplementalAdminAreas, DataSets). For this point, Key is T2011675.
2. Current conditions for that key
GET https://tempest.goalbert.ai/currentconditions/v1/T2011675
?apikey=KEY&language=en-us&details=trueCompare with the originals: GET https://dataservice.accuweather.com/locations/v1/cities/geoposition/search?apikey=…&q=…&language=en-us and GET https://dataservice.accuweather.com/currentconditions/v1/{locationKey}?apikey=…&language=en-us&details=true. Same paths, same parameters, and the same 32 fields in the same order and nesting; only the host changes. Without details=true both return the same 10-field summary, and errors come back in AccuWeather's shape (Code, Message, Reference). The demo's "Field check" compares the two documents field by field.
Location keys
A Tempest key is T plus the index of the nearest RTMA grid point (row × 2,345 + column). Like AccuWeather's keys it is an opaque string, so a client that takes Key from the search and passes it to current conditions works unchanged. It is not one of AccuWeather's numeric keys: an AccuWeather key such as 349727 gets a clear 400 error instead of the weather for some other place.
The data: NOAA RTMA Rapid Update
The Real-Time Mesoscale Analysis (RTMA) is the National Weather Service's gridded analysis of current conditions over the contiguous US. The Rapid Update variant produces a new analysis every 15 minutes (valid at :00, :15, :30, :45) and NOAA publishes each file to a public S3 bucket about 16 to 18 minutes after its valid time. One file is about 84 MB and carries 13 fields on a 2.5 km Lambert conformal grid of 2,345 × 1,597 = 3,744,965 points:
| Field | Meaning | Used for |
|---|---|---|
| HGT | Terrain height (m) | Pressure reduction, the location search's Elevation |
| PRES | Surface pressure (Pa) | Pressure (altimeter setting), wet-bulb temperature |
| TMP, DPT | 2 m temperature and dew point (K) | Temperature, DewPoint, RelativeHumidity, WindChillTemperature, WetBulbTemperature |
| UGRD, VGRD, WDIR, WIND, GUST | 10 m wind components, direction, speed, gust (m/s) | Wind speed and direction, WindGust |
| VIS | Visibility (m) | Visibility, ObstructionsToVisibility, fog, mist and haze |
| CEIL, TCDC | Cloud ceiling (m), total cloud cover (%) | Ceiling, CloudCover, WeatherText and WeatherIcon |
| SPFH | Specific humidity | Carried, not used yet |
The files are GRIB2 with simple packing: every value is a fixed-width integer (13 bits for temperature) scaled by a reference value and two scale factors, and there is no bitmap. That matters: a field is a contiguous run of bits in a known order, so any point, or any band of rows, is a plain byte range. Tempest reads GRIB2 directly with a few hundred lines of TypeScript (section walker, Lambert projection, bit unpacker) and needs no GRIB library or converter. NOAA also publishes a small .idx file listing where each field starts, which is how we find the 13 messages without scanning the file.
Ingest: from a NOAA file to two Durable Objects
- Discover. A cron trigger runs a Worker every 5 minutes. It lists today's and yesterday's prefixes in NOAA's bucket, picks the newest analysis file, and asks the
TempestVersionCoordinatorDurable Object tobegin(version). If that version is already known the run ends here, so polling more often than the data cadence is free. - Copy the raw file to R2. The Worker streams the 84 MB response straight into
raw/<version>.grib2(the response body goes to R2 as a stream of known length, never buffered: Workers have 128 MB of memory). R2 is S3-compatible and has no egress fees, so a pipeline that already writes weather files to S3 can write them to R2 with little change. - Build the manifest. Using the
.idxoffsets, the Worker range-reads the first 512 bytes of each of the 13 GRIB2 messages from R2, parses sections 1 to 7 (reference time, grid definition template 3.30, product definition, packing template 5.0, data offset), and writesmanifests/<version>.json: grid parameters plus, per field, the absolute byte offset of its packed data and its packing constants. - Fan out on Queues. The grid is cut into 13 bands of 128 rows. One message per band goes to a Cloudflare Queue; up to four consumers process bands in parallel, with retries and a dead-letter queue.
- Decode a band. A consumer computes, for each field, the bit range covering its 128 rows (rows × 2,345 points × 13 bits), range-reads just those bytes from R2, unpacks the integers and applies the GRIB2 scaling. It then cuts the band into 19 tiles of 128 × 128 points, interleaving the 13 fields per point (point-major Float32, 52 bytes per point, 852 KB per tile), and writes each tile to R2 and to every regional store.
- Commit barrier. After each band the consumer reports to the coordinator. Only when all 13 bands are in does the coordinator mark the version live and the previous one retired. Workers memoize the live version for 5 seconds (stale-while-revalidate), so a flip is visible everywhere within seconds, with no purge anywhere.
- Retention. Two versions are kept. When a version goes live, the coordinator hands out the versions that fell out of the window and their raw file, manifest and 247 tiles are deleted from R2. Steady state is about 600 MB of R2 and 195 MB per version in each store.
Measured in production: the first ingest went from discovery to a live version in 69 seconds, and the cron publishes each new analysis on its own after that.
Storage layout
R2 (durable source)
raw/<version>.grib2: the NOAA file as received.manifests/<version>.json: grid and field byte map.tiles/<version>/<x>/<y>.bin: 247 tiles, 852 KB each.
Anything a store is missing can be rebuilt from here; a store that never received a tile pulls it from R2 on first read and keeps it.
Regional store (Durable Object, SQLite)
- Table
tiles(version, tx, ty, payload BLOB), one row per tile, newest three versions retained (filling, live, previous). - A small LRU of hot tiles in memory (16 × 852 KB); everything else is a local SQLite read.
- A point read is one row lookup and a 52-byte slice: 13 floats.
The Durable Objects
A Durable Object is a single-instance, strongly consistent piece of compute with its own storage that lives in one Cloudflare data center. Tempest uses three classes:
| Class | Instances | Role |
|---|---|---|
TempestRegionStore | One per region: rtma:enam@1 in Atlanta (placement hint enam, Eastern North America) and rtma:wnam@1 in Dallas (hint wnam, Western North America) | Holds the whole US dataset for the retained versions. Receives tiles from the ingest consumers, serves point reads, self-heals from R2, detects and remembers its own data center. |
TempestVersionCoordinator | One | Commit barrier and bookkeeping: which version is preparing, tiling, live, retired or pruned; origin-traffic totals; the store generation (the @1). |
TempestRegionProbe | One per probe region (US West, US East, Europe, Asia-Pacific) | Demo only. Makes the public API call from where it lives so the page can show a request that genuinely enters Cloudflare elsewhere. |
Placement
Each store is created with a placement hint for its region. The hint fixes the region; Cloudflare chooses the data center inside it, places the object on first use, and keeps it there. On the first deploy it put the east store in Atlanta and the west store in Dallas. The number after the @ is a store generation kept by the coordinator: readers and ingest can move to a fresh pair of stores by changing that one number, and a new store fills any tile it lacks from R2 on first read.
Knowing where an object runs
There is no API for an object to ask its own location, but a subrequest made from inside an object leaves from the object's data center, so each store and probe fetches https://www.cloudflare.com/cdn-cgi/trace once, reads the colo= line, and persists it. That is the three-letter code shown as "Served by".
A request, step by step
- The call arrives at the Cloudflare data center nearest the caller and runs the Worker there. The
apikey(query parameter, or anX-Api-Keyheader) is checked in constant time (SHA-256 of both values,timingSafeEqual).languageis an AccuWeather language code (en-us,es-es, …);details=truereturns all 32 fields, otherwise the 10-field summary. - The Worker takes the live version and the store generation from a 5-second memo (one coordinator round trip per isolate, refreshed behind the response), and the manifest from a per-data-center cache in front of R2.
- It decodes the location key into a grid point and derives the tile (x, y) and the offset inside it. No I/O yet. (The location search did the projection: it maps the latitude and longitude onto RTMA's Lambert conformal grid, spherical earth, standard parallel 25°N, central meridian 95°W, and takes the nearest point.)
- It chooses the regional store for this caller (next section) and makes one RPC:
store.point(version, tx, ty, x, y). The object answers with 13 floats and its own data center. - The Worker derives the 32 fields, localizes the text, serializes exactly like AccuWeather, and responds with the one-element array plus
X-Tempest-*headers and aServer-Timingbreakdown (lookup, durable-object, derive, total).
Choosing the instance: only the caller's location matters
Every request carries request.cf.colo, the Cloudflare data center it entered. A US or Canadian data center goes to the nearer store by great-circle distance (Atlanta or Dallas); data centers outside North America are mapped by which coast is closer (Pacific-facing to the west, Atlantic-facing to the east); if a data center is unknown, the caller's longitude decides. When the demo simulates a user in a state, the page sends the state's code as X-Tempest-Caller (the Worker places the user at the state's center) through a probe in that half of the country, and the nearer store by great-circle distance wins, which is why Florida and Ohio go to Atlanta while Texas and Nebraska go to Dallas. If the chosen store fails, the other one answers and the response says so (X-Tempest-Replica-Reason).
Because each store holds the whole dataset, a New Jersey user asking about Los Angeles and a Seattle user asking about New York each get an answer from the store in their own region.
Simulating a user from a terminal
The demo's "I am in" menu is one optional header on the same call. With curl, add X-Tempest-Caller with a US state code (NV), a Cloudflare data center code (CDG: the user is in that data center's city) or a latitude,longitude; the URL stays identical to AccuWeather's. The strip at the top of the demo shows this header under the URL, and its curl button copies the exact call the page made:
curl -H 'X-Tempest-Caller: NV' 'https://tempest.goalbert.ai/currentconditions/v1/T2011675?apikey=048c68215b94e92d262007233d5f72a4&language=en-us&details=true'The store is then chosen for a user at the center of Nevada (the US West store, in Dallas), and X-Tempest-Caller-Simulated reports true. The request itself still enters Cloudflare where you are, so the time includes the trip to Dallas. The demo goes one step further and sends the call through a Durable Object in the user's half of the country, so it also enters Cloudflare there; the same is available to any client as GET /api/call-from?from=wnam&caller=NV&key=T2011675&apikey=KEY&language=en-us&details=true, which returns that call's status, headers, body and timing.
The document: AccuWeather's, field for field
The response is serialized by a schema copied from a captured AccuWeather response: a one-element array holding the same 32 fields in the same order, which is not alphabetical (LocalObservationDateTime, EpochTime, WeatherText, WeatherIcon, HasPrecipitation, …). Every measurement is a Metric and Imperial pair with AccuWeather's unit names and unit type codes (C 17, F 18, km/h 7, mi/h 9, mb 14, inHg 12, km 6, mi 2, m 5, ft 0). The rounding follows AccuWeather's too: Celsius to a tenth, Fahrenheit to a whole degree, and metric speed, pressure, visibility and ceiling derived from the rounded imperial value (9.8 mi/h is 15.8 km/h, 29.74 inHg is 1007 mb, 10 mi is 16.1 km).
Nothing is added to the body. Where the call came from and which Durable Object answered are response headers (X-Tempest-Caller-Colo, X-Tempest-DO, X-Tempest-DO-Colo, X-Tempest-Replica and the others listed under Operations), so a client that reads AccuWeather's fields sees only AccuWeather's fields.
| From the 13 raw values | Computed |
|---|---|
| TMP, DPT | Temperature and DewPoint; RelativeHumidity (Magnus); WindChillTemperature (NWS formula, equal to the temperature above 50 °F or below 3 mi/h); WetBulbTemperature (psychrometric equation, solved at station pressure) |
| WIND, GUST, WDIR | Wind.Speed (mi/h to a tenth, km/h derived), Wind.Direction in degrees and a 16-point compass (localized), WindGust (never below the sustained wind) |
| PRES, HGT | Pressure: altimeter setting (standard atmosphere), inHg to two decimals, mb derived |
| VIS | Visibility in whole miles from 1 to 10 (hundredths below 1 mile), km derived; ObstructionsToVisibility is an empty string at 10 miles, as in AccuWeather's responses, and null below that because RTMA does not say what restricts visibility |
| TCDC, CEIL, VIS | CloudCover; Ceiling rounded to 100 ft (null without a ceiling); WeatherText and WeatherIcon from AccuWeather's icon set (Sunny 1 to Cloudy 7, Fog 11, night icons 33 to 38) |
| time and place | LocalObservationDateTime with the point's UTC offset, EpochTime, IsDayTime (NOAA solar algorithm); for the location search, place, county, state and ZIP code from the US Census geocoder, time zone from the point, Elevation from HGT |
Text such as "Partly sunny" and the compass points come from a built-in dictionary in English, Spanish, Portuguese, French, German, Italian and Japanese; other languages fall back to English. Fields RTMA cannot provide (RealFeel, UV index, precipitation, pressure tendency, 24-hour history and summaries) are returned as null rather than invented, and so are MobileLink and Link, which point to AccuWeather's own pages; AccuWeather's feeds would fill them.
Why this is efficient
In a conventional design every edge-cache miss is an origin request: open the weather files, build the JSON, return it; and each new location key or language is a new cache key. In Tempest the request path never reaches an origin. The only data movement from outside is the scheduled ingest: 96 pulls a day of about 84 MB, the same whether there are ten users or ten billion requests. The demo page's "Origin traffic" card does the arithmetic live: at two trillion requests a month and a 95% cache hit rate, the conventional model sends 100 billion requests to an origin; Tempest sends 2,880 pulls, about 35 million times fewer.
Operations and security
- One hostname, one Worker.
tempest.goalbert.aiserves the demo at/, this write-up, and the API on the same origin; the demo makes the same two calls any client would, so a browser's network tab shows them. - Keys. The API key and the admin key are Worker secrets checked in constant time; nothing sensitive is in source or config.
- Headers for operators.
X-Tempest-Version,-Valid-Time,-Grid-Point,-Tile,-DO,-DO-Colo,-Replica,-Replica-Reason,-Caller-Colo,-Worker-Colo,-Timezone,-Translation,-Origin-BytesandServer-Timing; CORS is open and exposes them. - Observability. Workers Logs and Traces are on; every ingest step logs structured JSON (version, band, bytes, pruned objects).
- Testing. 40 tests run inside the Workers runtime: GRIB decoding against a real NOAA header, projection against known grid cells, tile format, Durable Objects, routing, and AccuWeather parity against a captured AccuWeather response (field names, order, nesting, units, unit types and rounding).
- Cost. A few dollars a month on a Workers Paid account: about 600 MB in R2, the Durable Object storage of two stores, 96 ingests a day, and request pricing in the noise for a demo.
Honest gaps
- RTMA covers the contiguous US only and has no RealFeel, UV, precipitation or history fields; those stay
null. - Location keys are Tempest's own, so a client that stores AccuWeather keys would need a one-time mapping. The location search knows only what the Census geocoder knows: no aliases,
Ranknull,DataSetsempty. - Two regional stores today (Atlanta and Dallas), and Cloudflare picks the city inside each hinted region. Adding Europe or Asia is a configuration line; sharding a region (by tile or by hash) is the path past roughly 1,000 requests per second per object.
- A global dataset with many more fields would not fit one object; sharding stores by tile handles that, at the cost of the serving city varying by tile.
- Data is 20 to 35 minutes old by construction of the public source.
Pick any point in the US, choose who is asking, and watch which Cloudflare data center answers.