The Michigan Lakes Case Study
A case study in joining six public data sources into one app: the request budget, a connection deadlock in production, and why an empty answer has to say why it's empty.
Published
A Question With No Source
Lake Finder answers one question: which lakes near me are worth fishing, and what does the state know about them? Michigan publishes almost every fact that question needs. It publishes none of them together.
OpenStreetMap knows where the water is and what people call it. The DNR knows depth, boat access, which species have been documented, what has been stocked and which waters hold state records, spread across half a dozen ArcGIS services. Depth maps are not a service at all: they are a directory of scanned PDFs, filed by county, with no index and no API in front of them. Nothing joins any of it, and none of the sources share a key.
So the app is the join, and the join is computed on every request. One Cloudflare Worker invocation, a hard deadline, a capped number of outbound calls, and a dozen ways for a public service to be slow rather than down. This is what the interesting half of that turned out to be: not the fetching, which is easy, but deciding what the app is allowed to claim when a source does not answer.
Six Sources, One Request
Every search fans out to these. The last column is the part worth designing for: what the reader should see when one of them is having a bad afternoon.
| Source | What it answers | Without it |
|---|---|---|
| Overpass (OpenStreetMap) | Named still water bodies in a bounding box, plus unnamed slipways. Read from public mirrors, raced against each other. | The candidate list falls back to whatever the DNR's own lake layer knows. |
| DNR hydrography | The state's own lake polygons, which is a different set from OSM's. | Lakes nobody has named in OSM disappear. Shupac and Kneff, the brook and brown trout lakes in Crawford County, were missing for exactly this reason: the app's whole stocking output for the county was Lake Margrethe's walleye, because it had never heard of the other two. |
| DNR inland lake map archive | Whether a depth map exists for one lake. Not a query: an HTTP request to a URL built by convention, where the status code is the entire answer. | Nothing can be shown at all. A confirmed depth map is this app's bar for listing a lake. |
| FCC census area | Which county a latitude and longitude sit in. | Also nothing, and less obviously. The archive is filed by county folder, so no county means no URL to check. |
| Zippopotam | A ZIP code's coordinates. | The ZIP box stops working. Searching by the device's own location still does. |
| DNR access, fish, records, stocking | Boat launches with surface and facilities, documented species, Master Angler waters, and the stocking report. | Lakes still list. They list plainer. |
The third row is the one that shapes the rest of this page. You cannot ask the archive what it has. You can only ask whether one particular file is there, one lake at a time, by building a path out of a county name and a lake name and reading the status code that comes back. Everything downstream is a consequence of that.
What a 26-Second Deadline Buys
An existence check per lake is cheap on its own and ruinous in bulk, so the budget is declared up front and everything else is sized against it.
const PER_CALL_TIMEOUT_MS = 10000; // A large radius covers a lake-dense area, so Overpass needs longer to answer. const OVERPASS_TIMEOUT_MS = 14000; const TOTAL_BUDGET_MS = 26000; // Selectable search radius (miles) -> the most mapped lakes we show. const MILES_LIMITS = { 15: 12, 25: 16, 40: 20, 100: 24 }; // How many nearby candidates we CHECK before filtering to the ones we show. // Larger than the display cap so a cluster of nearby mapless ponds can't // crowd the real lakes out of the results. const CANDIDATE_LIMITS = { 15: 18, 25: 22, 40: 24, 100: 28 }; // Only this many still-unmapped lakes get their own county geocoded. const DNR_COUNTY_FALLBACKS = 6;
Two of those pairs are worth pulling apart. The candidate cap sits above the display cap deliberately: filtering happens after the archive is consulted, so checking exactly as many lakes as you intend to show means one cluster of unmapped ponds can push every real lake off the list.
The county lookup is two-phase for the same reason a database query gets an index. Nearly every lake in a search shares the origin's county, so that county is geocoded once and tried against all of them with nothing but a cheap existence check each. Only the nearest lakes still without a map get their own county resolved, they may sit across a line, and only six of them, and not even those once the display cap is already full. The version without that bound geocoded per lake and timed out into an empty list on exactly the dense, lake-rich searches the app exists for.
Nine Stalled Responses
The existence check wants one thing from the archive: a status code. It asks for a single byte to avoid pulling down a scanned PDF it has no use for, reads the status, and moves on.
In production that jammed the whole request. A Response left
unread holds its connection open, and the runtime caps how many can be open
at once. Every candidate lake was probed concurrently, every probe kept its
connection, the cap filled, and the runtime started killing the oldest to
get out of it. The log line, nine times in a single request:
a stalled HTTP response was canceled to prevent deadlock. Every
other upstream call in that request queued behind the pile.
/* Let go of a Response whose status was the whole answer. * Any code path that decides against a Response without reading * it owes this call. Never throws: by the time it runs, the * status is already in hand. */ export async function dropBody(res) { try { if (res && res.body) await res.body.cancel(); } catch (e) { /* nothing left to protect */ } }
With that, the probe reads as it should: ask for a byte, keep the status, hand the connection back. Note what the status codes are allowed to mean. A 404 is the archive telling you there is no map for this lake, which is a fact. Anything else is the host having a problem, which says nothing about the lake at all, and the difference between those two is section 5.
async function urlExists(url, deadline) { if (timeLeft(deadline) < 1500) return null; try { const res = await fetchWithTimeout(url, { headers: { "User-Agent": UA, Range: "bytes=0-0" } }, Math.min(5000, timeLeft(deadline))); await dropBody(res); if (res.status === 200 || res.status === 206) return true; // A 404 is the archive telling us there is no map. Anything else // is the host having a problem, which says nothing about this lake. return res.status === 404 ? false : null; } catch (e) { return null; } }
Three return values, not two. true and false are
answers; null is the absence of one, and it is a different thing
from false in every way that matters to the reader. Most of the
bugs in this route have been some version of collapsing those two.
Empty Is Two Different Answers
"No lakes near Grayling" is a claim, and the search has to earn it. The same empty array is produced by a genuinely quiet stretch of map and by four services declining to answer, and those two owe the reader completely different sentences.
I have written this guard three times. The first two were wrong in the same way, and the way is worth more than the fix:
| Guard | How it was written | What fell through |
|---|---|---|
| First | unknown > 0 && found === 0 |
Any outage that produced no inconclusive checks, because it stopped the checks from happening. |
| Second | Neither water source could be read. | Both sources answering fine, and the depth-map stage failing wholesale afterwards. |
| Third | The conditions for confidence, stated positively. | Still to be found out. But it is no longer a list of the outages I happened to think of. |
The gap that actually shipped is a good illustration of why enumerating
failures does not work. The enrichment step is wrapped so a thrown error
becomes a zeroed tally, which is sound, except that a wholesale failure then
reports checked: 0, found: 0, unknown: 0 and a search that
genuinely found nothing reports exactly the same three zeros.
unknown > 0 was false. Eighteen candidates went out as an
empty lake district, and a debug run minutes later showed eleven of them had
depth maps.
So the third version asks whether the search is entitled to its answer, and treats anything less as an outage:
if (!lakes.length) { // Could the water sources be read at all? One reachable and // genuinely quiet is a real answer; neither reachable is the // double outage. if (!lakesResult.read && hydroDiag.queryOk !== true) { return { success: false, error: "Couldn't reach OpenStreetMap or the " + "Michigan DNR's water data, so there's nothing to search yet." }; } // There were candidates, so the depth-map gate is what emptied the // list, and it only gets to do that on candidates it RESOLVED. if (candidates.length && (mapTally.unknown > 0 || mapTally.checked < candidates.length)) { return { success: false, error: "The Michigan DNR's lake-map archive " + "is not answering, so depth maps could not be confirmed." }; } }
mapTally.checked < candidates.length is the load-bearing
clause, and it is the one no counter can report on its own. It does not ask
what went wrong. It compares what the stage claims it did against how much
work there was, so a stage that never ran is caught by arithmetic rather than
by having been anticipated. That generalises: when a failure and a
legitimate zero produce identical counters, stop reading the counters and
find something outside them to check against.
Saying Why, On the Ordinary Answer
Guarding the empty case still leaves the quieter problem: a short answer that is not empty. Ten lakes when there should be twenty is invisible from outside, and it is invisible from the inside too unless the response says what it started with.
So every answer carries a small record of what was considered and where it went. Deliberately not a debug block. Debug bodies are opt-in, skip the cache, and get read by somebody who already suspects something; the entire problem here is that nobody suspects anything.
emptiness: emptiness(candidates.length, "OpenStreetMap water bodies", { noDnrMap: /* checked, and the archive has no map */, mapUnchecked: mapTally.unknown, overCap: /* mapped, but past the display cap */, })
The first two are kept apart on purpose, and merging them is the mistake this record exists to prevent. "Eighteen candidates, eighteen without a map" reads as a lake district nobody has ever surveyed. It reads identically when the archive refused eighteen times. Two facts, two different fixes, two different sentences owed to the reader, and one number if you let them merge.
GET /api/lakes?zip=49738&miles=25
Calling the route when this section comes into view…
That is the real route, called live, drawn from the record on its ordinary cached response. Whatever it says today is what you are looking at: a full list draws the funnel that produced it, an empty one draws the funnel that emptied it, and an outage prints the guard's own sentence instead, which is section 5 working rather than section 5 failing.
What Keeps It Honest
A route this conditional decays quietly, so most of the maintenance cost is paid in machinery that fails loudly instead.
| Mechanism | The failure it exists for |
|---|---|
| Derived cache salt | Answers are cached for a day at the edge, so a fixed bug keeps being served. The cache key carries a digest of the route's own logic, generated by the build, so it moves exactly when the answer can move and there is no version constant to remember to bump. |
| Uncacheable debug | Debug answers used to go out through the route's ordinary responder, complete with its day-long cache header. Ask a route why it is empty, fix it, deploy, ask again, and get the old answer byte for byte. That happened, and the fields the fix added being missing from the reply is what gave it away. |
| Declared host policy | One table of per-host concurrency ceilings, checked by a contract test against the hostnames the code actually dials. It caught a ceiling declared for a portal this site has never fetched, while the two hosts carrying every ArcGIS request had none. |
| Status codes mean one thing | A handled failure is HTTP 200 with success: false and a sentence for a person. Only a malformed request or the wrong method uses another code, so a non-200 always means the request was wrong rather than the world. |
| A page that reads the record | The status page probes every upstream from the edge and reports each app's data check with its scanned count. "Answers, but with nothing, 36 scanned, dropped noAccess 36" names a bug. "Answers, but with nothing" does not. |
The thread through all of it is the same one sentence, which turned out to be the actual lesson of building on public data: an app is allowed to say what it knows, and it is allowed to say that it does not know, and the expensive bugs are all the third thing, where not knowing gets rendered as knowing.
The app is at Lake Finder, the upstreams it depends on are at Status, and the shape of the site around both is at How This Site Works.