Tempest

How we built this

A current-conditions weather API that runs entirely on Cloudflare.

Try it →

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.

Architecture

CLOUDFLAREREQUEST PATHDATA PATH · EVERY 15 MINUTESClientBrowser, curl, app, backendAny HTTPS clientSame calls as AccuWeather's APICloudflare WorkerRuns in the data center nearest the callerChecks the Tempest apikey (a Worker secret)location key → grid point → tile · JSONCache APIPlace, county, state, ZIPfrom the US Census geocoderkept 7 days per pointDurable Object: US East storertma:enam@1 in ATL (hint enam)Whole US dataset, live versionSQLite + hot tiles in memoryDurable Object: US West storertma:wnam@1 in DFW (hint wnam)Whole US dataset, live versionSQLite + hot tiles in memoryDurable Object: coordinatorCommit barrier: publish a versiononly when all 13 bands landedRetention of old versionsNOAA RTMA Rapid UpdatePublic S3 bucketNew analysis every 15 minutesGRIB2, ~84 MB, 13 fieldsIngest Worker (cron, 5 min)Finds the newest fileStreams it to R2, reads 13 headersEnqueues 13 row-band jobsQueues + Workers13 band jobs, 4 in parallelRange-read + decode GRIB2 bandCut 19 tiles → R2 and both storesR2raw/<version>.grib2manifests/<version>.jsontiles/<version>/x/y.bin (247)GET key, then conditionsJSON · 0 origin bytesplaceeast callerswest callersnew file13 jobsraw file + manifest (streamed)247 tilestilestilesband done
Top: a client's GETs go to the nearest Cloudflare data center, the Worker reads one point from the regional Durable Object on the caller's side, and the JSON comes back; no origin sits behind the call. Bottom: every 15 minutes NOAA's new analysis is pulled once, tiled through Queues, and pushed into both stores; the coordinator publishes the version only when it is complete (dashed: publish to the stores, and a store self-healing a missing tile from R2).

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-us

The 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=true

Compare 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:

FieldMeaningUsed for
HGTTerrain height (m)Pressure reduction, the location search's Elevation
PRESSurface pressure (Pa)Pressure (altimeter setting), wet-bulb temperature
TMP, DPT2 m temperature and dew point (K)Temperature, DewPoint, RelativeHumidity, WindChillTemperature, WetBulbTemperature
UGRD, VGRD, WDIR, WIND, GUST10 m wind components, direction, speed, gust (m/s)Wind speed and direction, WindGust
VISVisibility (m)Visibility, ObstructionsToVisibility, fog, mist and haze
CEIL, TCDCCloud ceiling (m), total cloud cover (%)Ceiling, CloudCover, WeatherText and WeatherIcon
SPFHSpecific humidityCarried, 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.

Data age: a reading is typically 20 to 35 minutes old when served (NOAA's ~17-minute posting delay plus our 5-minute poll and the minute or two of tiling). AccuWeather's own data is fresher and richer; RTMA is the right public stand-in for the architecture, not a replacement for AccuWeather's data.

Ingest: from a NOAA file to two Durable Objects

NOAA RTMA Rapid Update (public S3 bucket)New analysis every 15 minutes, posted ~17 min after its valid timeGRIB2, ~84 MB, 13 fields, 2,345 × 1,597 grid points (2.5 km)Worker on a cron (every 5 minutes)List the bucket, pick the newest file not yet ingestedcoordinator.begin(version): one ingest at a timeR2: raw/<version>.grib2 + manifests/<version>.jsonStreamed copy of the file (never buffered in the Worker)Manifest: grid definition, 13 field byte ranges, packing parametersQueues: 13 row-band jobsOne job per band of 128 grid rows, up to 4 consumers in parallelWorker consumers: decode one bandR2 range read of each field's bits for those rows (no GRIB library)Unpack 13-bit integers → floats, cut 19 tiles of 128 × 128 pointsTile = point-major interleaved Float32, 13 fields, 852 KBR2: tiles/<version>/x/y.binDurable source of every tile247 tiles × 852 KB per versionDurable Object rtma:enam@1 (ATL)US East store: all 247 tilesSQLite + hot-tile memoryDurable Object rtma:wnam@1 (DFW)US West store: all 247 tilesSQLite + hot-tile memoryDurable Object: VersionCoordinatorCounts bands; publishes the version only at 13/13Readers switch within 5 s, no purge anywherePrunes versions outside the retention window from R2stream, then read 13 headersband complete
Each new analysis flows down this pipeline once. Readers never see a partial version.
  1. 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 TempestVersionCoordinator Durable Object to begin(version). If that version is already known the run ends here, so polling more often than the data cadence is free.
  2. 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.
  3. Build the manifest. Using the .idx offsets, 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 writes manifests/<version>.json: grid parameters plus, per field, the absolute byte offset of its packed data and its packing constants.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

ClassInstancesRole
TempestRegionStoreOne 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.
TempestVersionCoordinatorOneCommit barrier and bookkeeping: which version is preparing, tiling, live, retired or pruned; origin-traffic totals; the store generation (the @1).
TempestRegionProbeOne 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

ClientBrowser, curl, appGET /currentconditions/v1/{Key}?apikey=…&details=true&language=en-usCloudflare WorkerRuns in the data centernearest to the caller(e.g. EWR for New Jersey)apikey check (constant time)Locate the pointLive version (memo, 5 s)location key → grid point→ tile (x, y) and offsetNo I/O so farChoose the storeBy where the request enteredCloudflare (or the simulateduser location), never by thepoint asked aboutDurable Object (store)Whole US dataset, live versionTile from memory or SQLite13 floats (52 bytes) for the pointSelf-heals a tile from R2Derive the document (in the Worker)32 fields in AccuWeather's order, Metric + Imperial pairs:RH, wind chill, wet bulb, compass, local time, icon 1 to 44Text from a built-in dictionary (7 languages)ResponseAccuWeather's one-element array, nothing added+ X-Tempest-* headers (store, colo, tile, timing)Cache-Control: no-store (demo) · origin bytes: 052 bytesto the client
The whole path stays inside Cloudflare; the regional store is the only hop.
  1. The call arrives at the Cloudflare data center nearest the caller and runs the Worker there. The apikey (query parameter, or an X-Api-Key header) is checked in constant time (SHA-256 of both values, timingSafeEqual). language is an AccuWeather language code (en-us, es-es, …); details=true returns all 32 fields, otherwise the 10-field summary.
  2. 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.
  3. 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.)
  4. 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.
  5. 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 a Server-Timing breakdown (lookup, durable-object, derive, total).

Choosing the instance: only the caller's location matters

Where did the call enter Cloudflare?request.cf.colo on the Worker,or X-Tempest-Caller when thedemo simulates a user locationUS data center nearer DallasLAX, SEA, DEN, PHX, DFW, IAH…Pacific-side data centerSIN, NRT, HKG, SYD…Simulated user (lat, lon)nearer store by great-circle distanceUS data center nearer AtlantaEWR, IAD, ORD, BOS, ATL, MIA…Atlantic-side data centerLHR, CDG, FRA, GRU, DXB…US West storeDurable Object rtma:wnam@1in DFW (hint wnam)US East storeDurable Object rtma:enam@1in ATL (hint enam)west if closereast if closerfallback if a store fails
The point the caller asks about never influences which store answers.

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 valuesComputed
TMP, DPTTemperature 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, WDIRWind.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, HGTPressure: altimeter setting (standard atmosphere), inHg to two decimals, mb derived
VISVisibility 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, VISCloudCover; 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 placeLocalObservationDateTime 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

Honest gaps

Seen enough? Go click the map.

Pick any point in the US, choose who is asking, and watch which Cloudflare data center answers.

Try it →