◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-10-21Intermediate

A Widget That Updates on Time in iOS Stays on "Initial" in Android

In expo-widgets, updateTimeline does nothing on Android and the generated XML hardcodes updatePeriodMillis to 0. How to recognize it, what updateSnapshot can cover, and a script to check your project.

expo-widgetsExpo SDK 583Android58WidgetsTroubleshooting48

You put a widget on the home screen, schedule a timeline that should change the display every minute, and wait. On iOS it flips after a minute. On the Android emulator it still says "Initial" five minutes later.

You suspect your code, then the build cache, then the device's battery settings, and an hour is gone. I've put the problem currently reported in the Expo repository into the order I'd check things. I have not reproduced it on a physical Android device; what follows rests on the issue text, Expo's reply, and a script I ran against mock files.

What I want to say first: this isn't a misconfiguration. On Android it simply isn't supported yet.

What is happening

The report used a development build on SDK 58:

  • Add the widget and it shows "Initial"
  • Call updateTimeline() from the app with five entries one minute apart
  • Nothing changes, however long you wait

According to Expo's investigation comment there are two causes. updateTimeline() returns early on Android, so scheduled entries are dropped. And the appwidget-provider XML generated by the config plugin has android:updatePeriodMillis="0" fixed in, with no option to change it.

So an Android widget cannot change itself unless the app calls updateSnapshot(). The maintainers answered that this is a missing Android feature rather than a regression, and a separate change proposes a docs note saying updateTimeline has no effect on Android. It was unmerged when I wrote this, so check the official docs for the final wording.

The order I'd check

  1. Run the same code on iOS. If it flips there, your logic is probably fine
  2. Check whether updateTimeline sits inside a Platform.OS === "android" branch. If it does, that call is currently a no-op
  3. Read updatePeriodMillis in the generated XML. 0 means no system-driven periodic update
  4. With the app open, call updateSnapshot(). If the display changes, rendering itself is healthy

Doubting battery settings before reading the generated XML tends to be the long way round. Reading the XML takes minutes.

Checking the generated XML mechanically

After npx expo prebuild -p android, this small script lists every appwidget-provider under the android directory with its interval. I confirmed it prints "no periodic update" on a mock XML; please run it on your own generated output to confirm the real behavior.

// Walks android/ for appwidget-provider files and lists their updatePeriodMillis.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
 
function* walk(dir) {
  for (const name of readdirSync(dir)) {
    if (name === "build" || name === ".gradle") continue;
    const p = join(dir, name);
    if (statSync(p).isDirectory()) yield* walk(p);
    else if (p.endsWith(".xml")) yield p;
  }
}
 
let stuck = 0;
for (const file of walk(process.argv[2] ?? "android")) {
  const xml = readFileSync(file, "utf8");
  if (!xml.includes("<appwidget-provider")) continue;
  const m = xml.match(/android:updatePeriodMillis="(\d+)"/);
  const ms = m ? Number(m[1]) : null;
  // 0 means no system-driven periodic update. Android rounds anything under 30 minutes up to 30.
  const note = ms === 0 ? "no periodic update" : ms === null ? "not set" : `${ms / 60000} min interval`;
  if (ms === 0 || ms === null) stuck++;
  console.log(`${file}: ${note}`);
}
console.log(stuck ? `widgets without periodic update: ${stuck}` : "all widgets have periodic update");

If it says "no periodic update", design that widget on the assumption that it only refreshes while the app is running.

What the app can do

updateSnapshot() works while the app is alive. Expo's reply also mentioned returning new props from a press handler on a widget button. A branch like this keeps the timeline on iOS and falls back to a snapshot on Android:

import { Platform } from "react-native";
import { MyWidget } from "./widgets/MyWidget";
 
type Entry = { date: Date; props: { label: string } };
 
export function scheduleLabels(entries: Entry[]) {
  if (Platform.OS === "ios") {
    // iOS: the system switches entries at the scheduled times
    MyWidget.updateTimeline(entries);
    return;
  }
  // Android: scheduling is not available. Swap in only what is current.
  const now = Date.now();
  const current = [...entries].reverse().find((e) => e.date.getTime() <= now);
  if (current) MyWidget.updateSnapshot(current.props);
}

I branch on Platform.OS so the call doesn't silently change behavior if Android support lands later. A comment saying "does nothing for now" saves your future self from the same confusion.

There is a limit, though. The reporter points out that calling updateSnapshot() from a background task needs the JavaScript runtime to wake each time, and under battery restrictions timing isn't guaranteed. That is fine for content that can drift by minutes, and a poor fit for "switch at 7 a.m. every day."

Pitfalls

  • Hand-editing updatePeriodMillis in the XML gets overwritten on the next prebuild. The real fix would be in the config plugin, which has no such option today
  • Android rounds updatePeriodMillis below 30 minutes up to 30, so even if it were configurable it couldn't drive minute-level changes
  • Don't assume that what passes on iOS passes on Android when it comes to widgets

Where I draw the line

As an indie developer running several wallpaper apps, a widget that changes daily is one of the features I most want to build. So I split the promise up front.

Promise time-based display on iOS; on Android, promise only that it catches up when the app opens. Never making the same promise on both platforms has cost me the fewest rewrites.

If you do need on-time changes on Android, writing native AlarmManager or WorkManager code yourself remains an option. It means extending what expo-widgets generates, and every SDK upgrade adds upkeep. I'd decide by asking whether anyone would be upset if the widget quietly stopped.

Next step

After your next prebuild, run the script once and see whether any of your widgets show "no periodic update." Then decide today whether that display becomes an iOS-only promise or a catch-up-on-launch design.

For iOS refresh budgets, see WidgetKit timeline refresh budgets. The Android build failure in the same package is covered in pin or exclude. Before moving to SDK 58, the removed-API audit script is also worth a look.

I intend to keep this one principle even as the feature list grows.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◇ App Dev2026-10-21
expo-widgets Works on iOS but Fails Only in the Android Build — Pin the Version or Exclude It?
Reports say expo-widgets 58.0.17 and later stops at Kotlin compilation on Android only. How to recognize it, when to pin versus exclude from autolinking, and a script that checks your project.
⬡ Dev Tools2026-10-15
When EXPO_PUBLIC_ Values Read Fine in Dev but Come Back undefined in Production: Telling the Spellings Apart
The API URL reads fine while developing and turns undefined once the app is built. The cause was how process.env was written. I lined up babel-preset-expo's output for dev and production and tabulated which spellings get replaced and which are left behind.
⬡ Dev Tools2026-10-14
My App Open Ad Only Showed When the App Came Back From Background. The 'Returned to Foreground' Signal Fired Before the Ad Had Loaded
On a cold start, the app open ad in my Android app never showed. The foreground signal came before the load began. Here is how I split the signal from the load and rebuilt it as logic you can test ordering by ordering.