Skip to content
All posts

Jetpack Glance in 2026: building a home-screen widget that never lies about your data

A practical guide to Jetpack Glance widgets on Android — state, click actions, and the update-quota trap that makes home-screen widgets show stale data.

MFKAPPS 5 min read

A widget only earns its home-screen space if it’s telling the truth. The moment it shows yesterday’s number, or a tap does nothing for three seconds, the user swipes it into a folder and never looks at it again. That’s a higher bar than it sounds — most of the widget code I’ve seen (including my first attempt) is honest right after installation and quietly wrong an hour later.

I added a home-screen widget to Hydrame this year — today’s water intake, one tap to log a glass, no need to open the app. Here’s the Jetpack Glance architecture that keeps it accurate, and the update-frequency trap that causes most of the “my widget is stuck” bug reports you’ll find if you search for them.

Why Glance instead of RemoteViews

The old AppWidgetProvider + RemoteViews API works, but it means writing UI twice — once in Compose for the app, once in XML-backed RemoteViews for the widget, with a much smaller set of supported views. Jetpack Glance removes that split: you write the widget in a Compose-like DSL, and Glance compiles it down to RemoteViews for you at render time.

class HydrationWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            val prefs = currentState<Preferences>()
            val consumedMl = prefs[intPreferencesKey("consumed_ml")] ?: 0
            val goalMl = prefs[intPreferencesKey("goal_ml")] ?: 2000

            GlanceTheme {
                Column(modifier = GlanceModifier.padding(12.dp)) {
                    Text("$consumedMl / $goalMl ml", style = TextStyle(fontSize = 18.sp))
                    Button(
                        text = "+ 250 ml",
                        onClick = actionRunCallback<LogGlassAction>(),
                    )
                }
            }
        }
    }
}

Two things stand out if you’re used to regular Compose: provideGlance runs on its own lifecycle, outside your app’s process most of the time, and the state it reads comes from Glance’s own Preferences store — not a ViewModel, not a StateFlow you’re holding in memory. The widget has to be able to render correctly cold, seconds after the phone rebooted and before your app has run at all.

State lives in a DataStore, not in Room directly

Glance widgets can’t hold a live Flow from a Room Dao the way an Activity can — there’s no long-running collector to keep it fresh. The pattern that works is a small Preferences DataStore per widget instance that gets written to whenever the underlying Room data changes, and read from when Glance renders:

suspend fun syncWidgetState(context: Context, dao: HydrationDao) {
    val today = dao.totalForToday()
    val manager = GlanceAppWidgetManager(context)
    val ids = manager.getGlanceIds(HydrationWidget::class.java)

    ids.forEach { id ->
        updateAppWidgetState(context, id) { prefs ->
            prefs[intPreferencesKey("consumed_ml")] = today
        }
    }
    HydrationWidget().updateAll(context)
}

Call syncWidgetState right after any write that changes today’s total — logging a glass, editing an entry, midnight rollover. That single call is what keeps Room (the source of truth) and the widget (a cached snapshot) from drifting apart. Treat the widget as a read model you refresh on write, not a live view.

The update-quota trap

Android limits how often a widget can be updated via AppWidgetManager — the platform docs describe roughly a 30-minute floor for periodic updates, and in practice OEM battery managers make even that unreliable. If you rely on updatePeriodMillis in the widget’s XML metadata and call it done, you’ll get a widget that’s correct once and stale for the rest of the day.

The fix is to stop treating the widget as something that polls, and start treating it as something you push to on every relevant event:

  • Trigger updateAll() directly from the write path (the code above), not from a background schedule.
  • Use WorkManager only for updates that genuinely need to happen with no app interaction — a midnight reset of “today’s total,” for example — and keep that job infrequent and battery-friendly.
  • Don’t fight the 30-minute floor with a tighter periodic worker. It won’t win, and it burns battery for a result the OS will throttle anyway.

Once the widget only updates in response to real events, the stale-data reports mostly disappear, because there’s no window where the widget is “waiting for its next poll.”

Click actions run in a separate process

actionRunCallback<LogGlassAction>() doesn’t call a method on your widget class — it invokes an ActionCallback that Glance instantiates fresh, with no guaranteed connection to any in-memory state your app currently holds:

class LogGlassAction : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters,
    ) {
        val db = AppDatabase.get(context)
        db.hydrationDao().logGlass(amountMl = 250)
        syncWidgetState(context, db.hydrationDao())
    }
}

Every dependency the callback needs — the database, in this case — has to be resolvable from Context alone, because there’s no Activity, no ViewModel, and often no other part of your app currently running. Write it as if the app process had just been killed, because on a real device it frequently has been.

What I’d tell anyone adding their first widget

  1. Design the widget as a read model, not a live mirror. Sync it on write; don’t poll for it.
  2. Assume cold start on every render. No ViewModel, no cached singleton you can count on — read from persistent storage every time.
  3. Respect the update floor instead of fighting it. A push-on-write widget stays accurate without needing a tight periodic schedule at all.
  4. Test after a reboot and after force-stop, not just after a fresh install. That’s where most widget bugs actually live.

Hydrame’s widget has been live for a few weeks now, and the lesson that stuck is the same one that shows up everywhere else in Android background work: the platform isn’t being difficult for no reason — it’s protecting battery from code that assumes it can poll whenever it wants. Design around that assumption instead of around fighting it, and the widget stays honest without much extra effort.