2025
WeatherToRun
Building a high-performance PWA with Next.js Edge Functions, multi-layer caching, and a custom weather scoring algorithm to help runners plan training sessions.
Case study
2025
Building a high-performance PWA with Next.js Edge Functions, multi-layer caching, and a custom weather scoring algorithm to help runners plan training sessions.
If you're a runner, you know the daily struggle: when should I run, and what should I wear? Most weather apps just show you numbers (temperature, humidity, wind speed) but they don't actually help you make a decision. I found myself doing mental math every morning, weighing all these variables in my head. Sometimes I'd get it wrong and end up overdressed, freezing, or caught in unexpected rain.
WeatherToRun started as a simple idea: what if the app could do that mental math for me? Instead of showing raw weather data and leaving interpretation to the user, it analyzes conditions and gives you a straight answer: is it a good time to run or not?
The core of the app is a scoring system that combines multiple weather factors into a simple 1-100 score. A color-coded "Bad," "OK," or "Good" rating makes the decision even faster. No more guessing.
Here's what I built:
The goal was to make something genuinely useful, something I'd actually reach for before heading out the door.
Before writing any code, I grabbed a pen and sketched out how the app should feel. What's the first thing a runner needs to see? The score and what to wear. Everything else is secondary. These rough wireframes helped me nail the hierarchy before getting lost in implementation details.



Initial wireframes exploring the main view, component structure, and user flow design.
I wanted WeatherToRun to feel like a native app: installable, fast, and functional even with spotty connectivity. But PWA caching isn't as simple as "cache everything." I learned this the hard way.
Different assets need different strategies. I used Workbox to set up:
When you're offline, you see a friendly offline page instead of a browser error. It's a small touch, but it makes the app feel more polished.
The app can be installed on your home screen and runs in full-screen mode. It feels native, even though it's just a web app.
Weather apps need to be fast. If someone has to wait, they'll just look out the window. But more importantly, they need to be reliable. External APIs go down. Rate limits kick in. Networks are flaky. I spent a lot of time making sure the app handles all of this gracefully.
The app doesn't just hope the weather API responds. It plans for failure:
I mentioned the scoring algorithm earlier, but here's the full breakdown:
| Factor | Weight | Why it matters |
|---|---|---|
| Temperature | 30% | 44-59°F is the sweet spot for performance |
| Wind | 25% | A 10mph headwind costs you 8-15s/mile |
| Dew point | 20% | Better than humidity for predicting discomfort |
| Precipitation | 15% | Rain, snow, storms |
| UV index | 10% | Sun exposure adds heat stress |
The algorithm also handles 30+ weather codes from thunderstorms to freezing rain, each with appropriate score penalties. And because weather apps often report misleading wind data, I blend gusts with sustained speed (70/30 weighting) for more accurate real-world scoring.
For discoverability, I added JSON-LD structured data across all page types (FAQ, Blog, Weather pages), dynamic Open Graph images for social sharing, and proper sitemap/robots.txt configuration. The technical SEO stuff that helps people find the app in the first place.
I integrated Sentry for error monitoring. When something fails in production, I get context (what operation failed, which coordinates, what the API returned) without exposing any user data. This has been invaluable for catching edge cases I never would have found in local testing.
I ran into an interesting problem with caching. Initially, I used Next.js's time-based ISR with a 2-hour revalidation window. Sounds reasonable, right? The issue is that ISR only triggers revalidation when someone visits after the stale period expires. For a page that doesn't get much traffic, a visitor might show up 12 hours after the last cache update and see 12-hour-old weather data on their first load.
That's not great for a weather app.
The fix was to stop waiting for visitors to trigger revalidation and instead proactively invalidate the cache on a schedule. I set up a cron job that hits a secure /api/revalidate endpoint every 30 minutes, regardless of traffic.
Here's what made this work:
next: { tags: ['weather'] } to the weather API fetch calls. This lets me invalidate all weather-related data at once using revalidateTag('weather') instead of manually tracking every cached page.revalidateTag(tag, 'max') which serves the cached content immediately while fetching fresh data in the background. Users never wait for the fetch. They always get an instant response.revalidate on the page still kicks in when traffic arrives.The endpoint itself is protected by a secret token stored in environment variables. Without the right REVALIDATE_SECRET, the endpoint returns a 401.
Now users always see weather data that's at most 30 minutes old, whether they're the first visitor of the day or the hundredth. Low-traffic pages stay just as fresh as high-traffic ones. It's a simple solution that works well.
Building WeatherToRun changed how I think about building apps. A few things stuck with me:
Less data, more synthesis. The value wasn't in showing more information. It was in processing it intelligently. The scoring algorithm took real research and thought, but it's what makes the app actually useful instead of just another weather dashboard.
Reliability is invisible until it's not. Users don't notice retry logic or timeout handling. They just notice when an app "works" or "doesn't work." The investment in graceful degradation pays off every time an API hiccups and the app keeps running.
Caching is harder than it looks. The multi-layer strategy (edge, client, coordinate rounding) wasn't obvious from day one. It evolved from understanding access patterns: weather data is stable for hours, and neighbors can share cache hits. But caching RSC payloads broke things in subtle ways. Sometimes the right answer is to not cache.
Edge changes everything. Moving to Edge Runtime dropped cold starts from 5 seconds to 50 milliseconds. For a quick-check utility app, this is the difference between feeling instant and feeling sluggish.
Small personalization goes a long way. Just adding a "More layers" / "Less layers" toggle made the clothing recommendations feel personal. Not everyone needs a settings page with 50 options. Sometimes one toggle is enough.