"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.
Requirements
Clarifying Questions
| Question | Answer |
|---|---|
| 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.
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 JSONfetchedAt 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.
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.
Watch it handle the situations it was built for:
-
The user opens the app.
ObserveForecastreads the Roomforecast_cacheand the screen paints the last forecast in one frame, with a "last updated" label. In parallelRefreshForecastresolves 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. -
The user pulls to refresh. The gesture calls
RefreshForecastdirectly, 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. -
The user opens the app with no network.
ObserveForecaststill 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. -
Hours later, the app still closed. On its own cadence, WorkManager's periodic job wakes once a network is available, runs
RefreshForecastin 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.
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.
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.
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.
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 forecastThis 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.
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.
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.
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.
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.
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.
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.
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.
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.
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."
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.
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.
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.
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.
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.
Tradeoffs
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Forecast serving | Stale-while-revalidate over a Room cache | Refetch on every open with a spinner | Renders in one frame and stays useful offline; the cost is a brief window showing a value the background fetch is about to replace. |
| Location fix | lastLocation fast-path | Block on a fresh getCurrentLocation fix | Returns 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 accuracy | ACCESS_COARSE_LOCATION | ACCESS_FINE_LOCATION | City-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 refresh | Constrained PeriodicWorkRequest | A foreground timer / Handler loop | WorkManager survives process death and gates on network; a timer dies the moment the app is backgrounded. The cost is a 15-minute interval floor. |
Related core concepts
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
Flowthat 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
UiStatethat distinguishes live, cached, and offline (Deep Dive 3).
Done reading? Mark it so it sticks in your dashboard.