
Routing around the weather: how we built ClimaRoute on AWS
How we built ClimaRoute, an app that finds the shadiest route in a heatwave and the driest one in a flood, using Step Functions, Lambda, Fargate and a lot of building shadows.
If you've walked anywhere in an Indian city in May, you know the feeling. It's 1 pm, it's 42°C, and your map app has just sent you down the one road with no trees and no buildings on the sunny side. Come July, the same app will happily route you through an underpass that's been knee-deep in water since the morning.
Map apps are very good at one thing: finding the fastest route. They don't know anything about the weather. For people who walk or ride a scooter every day, that's often the part that matters most.
So we built ClimaRoute. It finds the shadiest route when it's hot and the driest route when it's raining, and it's live in Bengaluru, Delhi, Mumbai, Chennai and Hyderabad.
Demo video: https://www.youtube.com/watch?v=lNYggc0OGhc
Live app: https://main.d1lxnvpn0ohz9a.amplifyapp.com
Example trip (Mumbai, 1 pm, Summer mode): https://main.d1lxnvpn0ohz9a.amplifyapp.com/#mumbai?from=19.03354,72.83923&to=19.02209,72.84267&mode=summer&by=walk&at=13:00
Codebase: https://github.com/Nithinaug/climaroute
What it does
You pick a start and a destination (search for a place, or just tap the map), and ClimaRoute draws two routes. The red one is the direct route. The green one is the safer route, and the app tells you what the detour buys you, something like "6 min longer · 32% shaded vs 21%".
There are two modes. Summer mode routes you through building shade for the exact time you're leaving, and warns you when the feels-like temperature or UV index gets dangerous. There's also a "Best time to leave" button, which checks the next few hours and tells you if waiting a bit gets you a cooler, shadier walk. Monsoon mode does the opposite job: it keeps you off low-lying streets that flood, using elevation data and the live rain forecast. If you switch to two-wheeler, it gets stricter, because a scooter stalls in water that you could still wade through on foot.
You can use either mode at any time of year. The map also follows the real sun, so it goes dark after sunset, and any trip can be shared as a link.
Shade is a lot of geometry
The idea is simple. For every 15 minutes of the day, we work out where the sun is (using NOAA's solar position formula), cast a shadow from every building by stretching its footprint away from the sun by
height / tan(elevation) , and then measure how much of each street falls inside those shadows.In code, a shadow is the building's footprint plus a copy of it pushed away from the sun, joined into one shape:
Python
length = heights / math.tan(math.radians(elevation_deg))az = math.radians(azimuth_deg)dx, dy = -length * math.sin(az), -length * math.cos(az) # away from the sunmoved = coords + np.column_stack([dx[index], dy[index]])return shapely.convex_hull(points) # footprint + moved copy
The problem is scale. Delhi alone has 1.39 million buildings and 654,000 walkable street segments, and we need all of that for 96 slots in the day.
The data comes from three places: building footprints from Overture Maps, heights from Google Open Buildings, and streets and trees from OpenStreetMap. A lot of buildings don't have a height at all, so we fill those in with sensible defaults based on the type of building. When you ask for a route, we blend the two nearest 15-minute slots, so the shade changes minute by minute rather than jumping every quarter hour.

Cutting the city into tiles
What made this workable was realising that shade is local. A building only casts a shadow on streets near it. That means we can cut each city into 500 m tiles and shade every tile on its own. Bengaluru turns into 5,025 tiles, Delhi into 4,842.
Thousands of small, independent jobs is pretty much exactly what Step Functions' Distributed Map is for. Our pipeline has four steps:
Plain text
SetSun (Lambda) work out the sun's position for every 15-min slotReadIndex (S3) read the list of tilesShadeTiles (Map) one Lambda per tile, 50 running at onceMerge (Lambda) stitch the results back into the city's street graph
The first time we watched the Map Run view count up to 5,025 processed and 0 failed was a good moment. Bengaluru took about 29 minutes, and shading a whole city costs us about $1.50 to $2.

Building the city data
Before any of that can run, each city has to be put together: download the OpenStreetMap extract, pull Overture buildings for the area, attach heights, read the Copernicus 30 m elevation model, and build the street graphs. That takes too long and uses too much memory for Lambda, so it runs as an ECS Fargate task, with one task definition per city. Everything ends up in S3: the raw data, terrain, tiles and finished graphs.
Where does the water go?
For flooding, the terrain does most of the work. From the elevation model we fill in depressions and compute flow accumulation, which is a way of estimating how much water drains into each spot. Every street gets a risk score from that. We also added 43 well-known waterlogging spots across the five cities, which mark the streets around them as risky.
When you ask for a route, that score gets multiplied by how much it's raining. Rain from the last few hours still counts (we assume it drains with a half-life of about 1.5 hours), and so does rain forecast for the next hour. Streets above a threshold get blocked, with a lower threshold for two-wheelers.

Serving a route in about a second
The part you actually use is fully serverless. The React and MapLibre frontend is on Amplify Hosting. Each city has its own backend behind an API Gateway HTTP API, and that backend is a Lambda running FastAPI (through Mangum) as a container image.
The Lambda loads the city's street graph from S3, stored as compact numpy arrays so a whole city fits in memory. For each request it recomputes the cost of every street (its length times a heat or flood penalty) and runs Dijkstra with scipy. That takes about half a second. This is most of the routing logic:
Python
if mode == "summer":sun = 1 - street_shade(net, slot)[net.street]w = net.length * (1 + config.ALPHA[transport] * heat * sun)else:risk = net.terrain[net.street] * rain_factor(rain_mm)w = net.length * (1 + config.BETA[transport] * risk)w = np.where(risk > config.BLOCK_THRESHOLD[transport], BLOCKED, w)dist, pred = dijkstra(net.matrix(w), indices=source, return_predecessors=True)
Weather comes from Open-Meteo on a roughly 9 km grid, fetched in one call and cached for 10 minutes. Place search, and the street name you see when you tap the map, come from Amazon Location Service.

Container images saved us a lot of pain, because the geospatial Python stack is far too big for a normal Lambda zip. The downside was cold starts, which we'll get to below.
Keeping the bill small
Putting a pay-per-request backend on the public internet is a bit nerve-wracking, so we set up a few guardrails early on. API Gateway limits each city to 20 requests a second, and 5 a second on the search and place-name routes. Amazon Location calls are capped at 2,000 per city per day using a counter in DynamoDB. If we ever hit that, search pauses until midnight but routing keeps working. And AWS Budgets emails us at $5, $15 and $30.
A single route costs about $0.00002. Across all five cities, the whole project has run on a few dollars.
Everything is in Terraform, with each city as its own workspace, so adding a sixth city is a new settings file and a deploy.
What fought back
Amazon Location's two surprises. Searching for nearby places kept failing with no useful error. The cause was a missing IAM permission (geo-places:SearchNearby). Then "park" turned out not to be a valid category: Amazon Location wants its own IDs, so it has to be "park-recreation_area". Now we check IAM first whenever an AWS call fails quietly.
The 6-second first route. Our routing API is a big container image, and the first request of the day took about 6 seconds while Lambda loaded it. An EventBridge rule now pings it every 5 minutes. It has two targets, because a single ping keeps only one instance warm, and a second request arriving at the same moment would still hit a cold one.
The demo that kept freezing. We recorded the demo by driving the live app with a script. It froze in the same two places every time, for 15 to 25 seconds, on half-loaded map tiles. The cause wasn't the app: intercepting network requests in the test browser had quietly switched off its cache, so every tile was downloaded again. Moving that logic into the page fixed it immediately.
"Klyma Route". Amazon Polly's generative voice kept saying our name wrong, so the narration script spells it the way it sounds.
What we learned
The biggest one: look for the independent unit of work. Shade felt like a job for a big server until we thought of it as "one tile, one Lambda". After that, a whole city took under half an hour of parallel work.
We also got better at picking the right compute for each job. Fargate handles the long, memory-hungry city builds, and Lambda handles thousands of tiny tile jobs and the fast API calls. Trying to force either one to do the other's job would have been painful.
And it's worth adding cost guardrails from day one. Throttling, usage caps and budget alerts are only a few lines of Terraform, and they meant we could leave a public app running without worrying about it.
What's next
There's a lot we'd still like to do. The big one is flood reports from people on the street, where a road counts as flooded once two people nearby report it, and others can confirm it or clear it. Traffic is another interesting signal, since roads where traffic suddenly stops during heavy rain are often under water. For Mumbai, we'd like to raise flood risk at high tide, and we want to pull in IMD warnings and city flood-monitoring systems where they exist.
If you walk or ride through Indian summers and monsoons, give it a try. We'd really like to hear where it helps, and where it gets things wrong.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article