← All problems

Mobile System Design

Weather App

easyFreeAsked at
HubSpotAmazonUberMeta

"Design a weather app like the Google or Apple weather app."

Analyzing the Problem

The Apple and Google weather apps show the current temperature and sky, the forecast for the user's location, and how old the numbers are. The app paints the last-known forecast instantly, refreshes it in the background without being asked, and labels stale data honestly rather than dressing it as live. Get that loop right and the app feels effortless; get it wrong and it shows yesterday's sunshine while it's raining outside.

Phase 01

Requirements

Clarifying Questions

QuestionAnswer
One location, or can the user save several cities?Start with the current location; allow a small handful of saved cities, but the design centers on one.
How fresh does the forecast need to be?Weather changes slowly - an hour-old forecast is fine. We don't need real-time updates.
Does it need to work offline?Yes - opening offline should show the last forecast, clearly marked as not live, never a blank screen.
Do we need maps, radar, or severe-weather push alerts?No - out of scope. The center is: see the current location's forecast, kept reasonably fresh.

Functional Requirements

  • Open the app and see the current location's forecast immediately.
  • Pull to refresh the forecast on demand.
  • See the last forecast offline, marked as not live.
  • The forecast stays reasonably fresh on its own, without the app open.
  • Radar maps and animated precipitation layers.
  • Severe-weather push alerts.

Non-Functional Requirements

  • Open latencyThe forecast is on screen the instant the app opens, without waiting on the network.
  • OfflineA cached forecast always shows offline, with an honest freshness signal.
  • FreshnessThe forecast is kept within ~1-3 hours of current without the user refreshing.
  • Battery and dataBackground refresh respects battery and only runs when there's a network.
Phase 02

Data Model + API

Entities and the wire

The screen needs just one entity. A Forecast is everything we show for one location at one point in time, and it travels as a single small JSON blob.

data class Forecast(
  val locationName: String,    // "San Francisco" - resolved from the coordinates
  val current: CurrentWeather, // temp, condition code, feels-like
  val hourly: List<HourSlot>,  // next ~24 hours
  val daily: List<DaySlot>,    // next ~7 days
  val fetchedAt: Instant,      // when WE fetched it - drives the "last updated" label and TTL
)

The cached copy is one durable row, keyed by location, that survives the app being killed. We persist it through Room, Android's SQLite layer, so a cold open can paint the screen from disk before the network answers, and an offline open has something to show at all.

@Entity(tableName = "forecast_cache")
data class CachedForecast(
  @PrimaryKey val locationKey: String,  // rounded lat,lon (or a saved city id)
  val payloadJson: String,              // the serialized Forecast
  val fetchedAt: Long,                  // epoch millis - TTL and "last updated" both read this
)

The wire surface is a single REST endpoint. The client sends coordinates (or a city) and gets back the whole forecast in one response - small enough that there's no pagination, no streaming, no second round-trip.

GET /v1/forecast?lat={lat}&lon={lon}&units=metric
      -> 200 { locationName, current, hourly[], daily[] }   # ~5-20KB JSON
Note
fetchedAt is the client's clock, not the server's - it records when this device pulled the bytes, so the "last updated 20 min ago" label and the staleness check both read the same field. A server-side timestamp would measure when the data was produced, which is a different question - and the catch worth naming: if a CDN (a content delivery network - geographically distributed cache servers) serves a cached-but-stale response, fetchedAt still reads "just now" while the data is hours old, the same freshness lie this app exists to avoid, one hop upstream.

Why Room for such a small payload

A forecast is only a few KB, so caching it in a database looks like overkill. Size is not why it's here. Two things justify Room: a cold open paints from disk in a single frame instead of blanking for the network round-trip, and an offline open still has a last-known forecast to show instead of an error. Room also hands the UI its table as an observable Flow, so any writer (pull-to-refresh, the background worker, the first load) redraws the screen just by updating the row. The forecast currently on screen lives in the fast in-memory tier (L1); Room is the durable copy behind it.

Phase 03

High-Level Design

The whole app is organized around one promise: the forecast is on screen the instant you open it, and it never dresses up stale data as live. A WeatherRepository resolves the device location, fetches from the Weather REST API, and keeps a durable copy in Room; a WeatherViewModel renders whatever that cache currently holds as one UiState; and a WorkManager-driven RefreshWorker keeps the cache warm in the background. The parts that make it actually feel native go to the deep dives: stale-while-revalidate serving, the location fallbacks, and the honest offline state.

Note
WorkManager - the Android API for background work that has to outlast the app itself. You describe the job and the conditions it needs (a network, a healthy battery); the system schedules it, holds it back until those conditions are met, and reruns it after a crash or reboot. For a forecast that should be current before you even open the app, a recurring WorkManager job is the natural fit; the background work and scheduling concept has the details, and the last deep dive drives it.

Watch it handle the situations it was built for:

  1. The user opens the app. ObserveForecast reads the Room forecast_cache and the screen paints the last forecast in one frame, with a "last updated" label. In parallel RefreshForecast resolves the location and fetches the latest; when it lands it writes Room, and because the UI observes that table, the screen swaps to fresh data on its own. The user never sees a blank screen.

  2. The user pulls to refresh. The gesture calls RefreshForecast directly, which re-resolves the location, re-fetches, and writes Room. A spinner tied to the gesture shows until the write lands, then clears. This is the on-demand path; the user asked, so the spinner is honest.

  3. The user opens the app with no network. ObserveForecast still paints the cached forecast. The fetch fails, so instead of a spinner the screen shows an offline banner alongside the cached value and its "last updated" time. The app stays useful with the last thing it knew.

  4. Hours later, the app still closed. On its own cadence, WorkManager's periodic job wakes once a network is available, runs RefreshForecast in the background, and writes Room - so the next open is already fresh, and a home-screen widget (if present) updates too. The OS picks the moment; the user is not part of this loop at all.

Phase 04

Deep Dives

The draft works; the deep dives are what make it feel native. Their order follows what a user notices first: serving the forecast from cache so the screen is never blank, then getting a location fast enough not to stall it, then keeping the screen honest about how old the numbers are. A shorter final dive handles the background refresh that keeps the cache warm between opens.

Scenario
A forecast is a few KB and changes slowly, but the network is slow and sometimes absent. Refetch on every open and the user stares at a blank screen for the round-trip - or forever, offline. How do we render a forecast the instant the app opens, and still keep it fresh?
BadRefetch on every open, spinner until it lands

The obvious first cut: on open, show a spinner, fire GET /v1/forecast, render when it returns. It's simple and always shows live data - when the network cooperates. But every single open costs a full round-trip of blank screen, and offline the spinner never resolves: the app is useless exactly when the user most wants to glance at it. The deeper problem is that we had a perfectly good forecast from five minutes ago and refused to show it.

GoodRoom single-row cache with a TTL

Persist the last forecast in the forecast_cache row and check its age on open. Within the time-to-live (TTL), render the cache and skip the network entirely; past it, fetch and overwrite.

Kotlin - TTL check on open
val cached = forecastDao.get(locationKey)
val fresh = cached != null &&
  (now() - cached.fetchedAt) < TTL_MILLIS    // e.g. 1 hour
if (fresh) return cached.toForecast()        // no network at all
val forecast = api.getForecast(lat, lon)     // stale or empty: fetch
forecastDao.upsert(forecast.toCached(locationKey))
return forecast

This is a real improvement: fresh opens are instant and offline-within-TTL works. But it splits the world in two. Past the TTL we're back to the bad case (a blank wait, or an offline failure) even though we're holding a forecast that's an hour and one minute old and perfectly fine to show while we refresh.

GreatStale-while-revalidate over the cache

Decouple what we render from whether we refresh. We always render the cached forecast immediately, whatever its age, and we separately decide whether to kick off a background refresh. The TTL no longer gates rendering; it only gates the refresh.

Kotlin - render cache now, revalidate behind it
fun observeForecast(locationKey: String): Flow<Forecast> = flow {
  forecastDao.get(locationKey)?.let { emit(it.toForecast()) }   // instant, any age
  if (isStale(locationKey) && online()) {
    val fresh = api.getForecast(lat, lon)
    forecastDao.upsert(fresh.toCached(locationKey))             // Flow re-emits fresh
  }
}

Because the UI observes the Room table as a Flow, the refresh doesn't need a callback into the view - writing the new row re-emits, and the screen swaps from stale to fresh on its own. The user sees a forecast in the first frame; if it was stale, it quietly updates a moment later. Offline, the refresh is skipped and the cached value simply stays - the honest-offline dive below handles how the screen says so.

This is the standard cache pattern; libraries like OkHttp's response cache and Store implement the same stale-while-revalidate shape. The seam is worth stating outright: render and revalidate are two independent decisions over one cached row.

Note
The TTL controls the refresh while the render happens regardless - past the TTL the cached forecast still paints in the first frame; all the TTL decides is whether we also fire a background fetch behind it to replace it.
Scenario
The forecast needs coordinates, and a fresh GPS fix can take several seconds with the radio cold - far too long to block the screen on. Worse, the user might not have granted location at all. How do we get a usable location fast, and stay useful when we can't?
BadBlock the UI on a fresh GPS fix

Request a fresh fix on open and wait for it before fetching: getCurrentLocation(...) then getForecast(...). The problem is timing - a cold GPS fix can take several seconds, especially indoors, and the entire screen sits empty behind it. We've reintroduced the blank-open we just spent the last dive eliminating, this time blocking on the sensor instead of the network.

GoodlastLocation fast-path with a rationale and coarse accuracy

Android's FusedLocationProviderClient keeps the OS's last-known fix, and reading it returns in milliseconds with no new GPS work. For a forecast that's almost always good enough - the user hasn't teleported since the last fix - so we read lastLocation first and only fall back to a fresh getCurrentLocation request when it's null.

Two more decisions ride here. We request ACCESS_COARSE_LOCATION, not ACCESS_FINE_LOCATION: city-block accuracy is plenty to pick a forecast grid cell, and coarse is the lighter permission users are far more likely to grant. And we ask for it at point-of-use with a one-line rationale ("to show weather where you are"), when the user opens the forecast - not in a context-free prompt at launch, which gets reflexively denied.

Kotlin - last-known fix, fresh request as fallback
val last = fused.lastLocation.await()        // milliseconds, may be null
val loc = last ?: fused.getCurrentLocation(  // fresh fix only if needed
  Priority.PRIORITY_BALANCED_POWER_ACCURACY, // coarse-grade, radio-friendly
  null,
).await()

This loads fast in the common case. What it doesn't handle is the user who said no.

GreatGraceful degradation to a saved city

lastLocation can be null - a fresh boot, location services off, a device that's never been located - and the permission can be flat-out denied. A staff answer treats "no location" as a normal state, not an error. When we can't resolve a position, we fall back to a saved or default city the user can set, and the app stays fully useful: it's a weather app for a place, just not an auto-detected one.

So location resolution is a tiered fallback: lastLocation -> fresh getCurrentLocation -> saved city. Each tier is the previous one's failure handler, and the bottom tier never fails, so the screen always has coordinates to fetch. Permission denial routes straight to the saved-city tier and surfaces a quiet "set your location" affordance rather than an error screen.

Note
Treat a missing location as an ordinary case to handle - a denied permission or a null fix degrades to a saved city, so the forecast screen always has somewhere to show weather for.
Scenario
The app shows a cached forecast that might be minutes or hours old, and the network might be gone. The danger is a screen that looks live but isn't - or a spinner that spins forever offline. How do we keep the user honestly informed about what they're seeing?
BadShow the cached forecast silently, no freshness signal

The cache works, so just render it - no timestamp, no banner. But now the user can't tell yesterday's forecast from this minute's. They glance at "sunny, 22 degrees," leave the umbrella, and walk into the rain that rolled in after the cached fetch. Showing stale data as if it were live is worse than showing nothing, because the user acts on it.

GoodPull-to-refresh and a 'last updated' timestamp

Give the user both a freshness signal and a way to act on it. Render a "last updated 20 min ago" label derived from fetchedAt, so the age is always visible, and wire a pull-to-refresh gesture that calls RefreshForecast on demand with a spinner tied to the pull. Now staleness is legible and the user has an explicit lever to fix it.

This is solid for the online case. It's still underspecified for the one that matters most: what the screen says when the pull-to-refresh fails because there's no network.

GreatAn offline banner over the cached value, never a lying spinner

Drive the screen off a discriminated UiState that distinguishes the cases the user must tell apart: live-and-fresh, showing-cached-while-refreshing, and offline-with-cache. The rule that ties it together: a spinner means "work is happening, wait for it." Offline, no work is happening, so a spinner is a lie - we show the cached forecast with an offline banner and the "last updated" time instead.

sealed interface UiState {
  data class Live(val forecast: Forecast) : UiState           // fresh, online
  data class Cached(val forecast: Forecast, val refreshing: Boolean) : UiState
  data class Offline(val forecast: Forecast) : UiState         // cached, no network
  data object Empty : UiState                                  // no cache, no network
}

Each state renders a different truth: Live shows the forecast plainly, Cached shows it with a quiet refresh indicator, Offline shows it with a banner and the age, and the rare Empty (first run with no network and no cache) is the only honest place for an empty state with a retry. The spinner appears only when a fetch is genuinely in flight - on first load or a pull-to-refresh - never as a stand-in for "we don't know."

Note
A spinner is a promise that data is coming - offline it's a broken one, so the offline state renders the cached value plus a banner, and reserves the spinner for fetches actually in flight.
Scenario
The forecast should already be current when the user glances at the widget or opens at 7am. But background work that drains the battery or wakes a dead radio is its own failure. How do we keep the cache warm without the app open and without the battery cost?
BadA foreground timer or Handler loop

The naive reach is a repeating timer: a Handler.postDelayed loop or a Timer that re-fetches every hour. It works exactly as long as the app is alive - and the OS kills a backgrounded process whenever it wants memory. The timer dies with it, the cache goes stale, and nothing reschedules. Background-as-a-timer is the classic mistake: it only runs while you don't need it to.

GoodA PeriodicWorkRequest

Hand the recurring fetch to WorkManager so it survives process death and reboot. A PeriodicWorkRequest reschedules itself on its cadence whether or not the app is running.

Kotlin - periodic refresh, deduplicated
val refresh = PeriodicWorkRequestBuilder<RefreshWorker>(3, TimeUnit.HOURS).build()
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
  "forecast-refresh",
  ExistingPeriodicWorkPolicy.KEEP,   // don't stack duplicate schedules
  refresh,
)

The unique name keeps repeated enqueues from stacking duplicate schedules. A periodic job's floor is a 15-minute interval, so this keeps the cache warm rather than real-time; pull-to-refresh covers anyone who needs this-second data. What the bare job still gets wrong is that it runs even when it can't succeed - waking on a dead network to fail a fetch - and it ignores the one surface that benefits most from background freshness.

GreatConstraints and the widget update

A bare periodic job still wakes on a dead network and wastes a fetch, and it forgets the surface that benefits most from background freshness. Two refinements. Constraints: gate the worker on NetworkType.CONNECTED (and optionally battery-not-low) so it only runs when it can actually succeed, instead of spinning up the radio to fail. The widget: the same worker that writes the Room cache also pushes the new forecast to a home-screen widget, so the glanceable surface is fresh without the app ever being opened.

Kotlin - run only when it can succeed
val refresh = PeriodicWorkRequestBuilder<RefreshWorker>(3, TimeUnit.HOURS)
  .setConstraints(
    Constraints.Builder()
      .setRequiredNetworkType(NetworkType.CONNECTED)
      .setRequiresBatteryNotLow(true)
      .build())
  .build()

The worker's body is the same RefreshForecast the foreground uses - resolve location, fetch, write Room - so the cache has exactly one writer path whether the trigger is an open, a pull, or the scheduler. The open app picks up the worker's write through the same observed Flow; nothing special connects them.

Note
Background refresh keeps the cache warm, not real-time - the 15-minute periodic floor means glance-freshness, with pull-to-refresh as the on-demand escape hatch for this-second data.

Tradeoffs

DecisionChosenAlternativeWhy
Forecast servingStale-while-revalidate over a Room cacheRefetch on every open with a spinnerRenders in one frame and stays useful offline; the cost is a brief window showing a value the background fetch is about to replace.
Location fixlastLocation fast-pathBlock on a fresh getCurrentLocation fixReturns in milliseconds so the forecast loads at once; the cost is the cached fix can be stale or null, so a fresh request is the fallback.
Location accuracyACCESS_COARSE_LOCATIONACCESS_FINE_LOCATIONCity-block accuracy is plenty for a forecast grid cell and is far more likely to be granted; fine adds GPS cost for no benefit here.
Background refreshConstrained PeriodicWorkRequestA foreground timer / Handler loopWorkManager survives process death and gates on network; a timer dies the moment the app is backgrounded. The cost is a 15-minute interval floor.

The mechanisms on this page are each developed in depth in a core concept:

  • Caching Strategies - the stale-while-revalidate pattern and TTL behind serving the forecast (Deep Dive 1).
  • Persistence - Room as a durable single-row cache and the observable Flow that drives the UI.
  • Network Protocols - the single REST forecast fetch and conditional-request basics.
  • Platform Knowledge - FusedLocationProviderClient, the location permission model, and coarse vs fine accuracy (Deep Dive 2).
  • Background Work and Scheduling - PeriodicWorkRequest, constraints, and the interval floor for the warm-cache refresh (Deep Dive 4).
  • Offline-First and Sync - serving cached data offline and the honest-freshness contract (Deep Dive 3).
  • State Management - the discriminated UiState that distinguishes live, cached, and offline (Deep Dive 3).

Done reading? Mark it so it sticks in your dashboard.

Next in System DesignNotes App