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
- Run the same code on iOS. If it flips there, your logic is probably fine
- Check whether
updateTimelinesits inside aPlatform.OS === "android"branch. If it does, that call is currently a no-op - Read
updatePeriodMillisin the generated XML.0means no system-driven periodic update - 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
updatePeriodMillisin the XML gets overwritten on the nextprebuild. The real fix would be in the config plugin, which has no such option today - Android rounds
updatePeriodMillisbelow 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.