●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Keeping Rork Apps Fresh While Closed — iOS BGTaskScheduler, Silent Push, and Android WorkManager
A production-quality implementation guide for background refresh in Rork-built apps, covering iOS BGTaskScheduler, APNS Silent Push throttling, Android WorkManager, and the user-recovery funnel that ties it all together.
"The time when an app is closed is where most apps win or lose," an older developer told me years ago, and I have remembered it ever since. When I started building apps on my own, I only paid attention to the time users were actively in front of my screens. After a few years of running them, I came to feel — almost painfully — that what really mattered was what I could prepare during the time the app was closed. Whether the new feed appears within three seconds of launch, or only after five, will quietly move next-day retention by a point or two. I have watched it happen on my own dashboards more times than I can count.
When you try to add this kind of "stay-fresh-while-closed" behavior to an app generated by Rork, the differences between iOS and Android start to confuse you. Silent Pushes that you sent never seem to arrive. The BGTaskScheduler you registered does not fire. Android's WorkManager goes silent under Doze. You wrote it the way the docs said, and yet only half of what you intended is actually happening in production. Over the years of running apps that have crossed 50 million cumulative downloads, I have walked into this trap many times.
This article gathers what I learned, and walks through how to add production-grade background refresh to a Rork-built app — both the code patterns and the operational decisions that surround them.
Understand the iOS and Android background-execution models accurately
The first thing to do is to be very precise about what each OS will let you do in the background. If you are vague here, you will end up with an implementation that works on one platform and quietly does nothing on the other.
On iOS, background refresh has three main paths. BGAppRefreshTask is for short (around thirty-second) refreshes that the system schedules according to its model of when each user opens your app. BGProcessingTask is for longer work, lasting several minutes to a few hours, optionally constrained to charging or Wi-Fi. And Silent Push — APNS notifications with apns-push-type: background and content-available: 1 — is the server-driven trigger. These three are not mutually exclusive. In practice you combine them so that something gets through, even if any one channel is throttled.
On Android, the answer in 2026 is essentially WorkManager. The combination of OneTimeWorkRequest and PeriodicWorkRequest, the immediate execution path of ExpeditedWorkRequest, and the Constraints for network and charging conditions, are enough to produce a usable execution rate even under Doze and App Standby. The big advantage of WorkManager is that it absorbs OS-version differences for you — the same code runs on Android 8 through 15. As an indie developer, I cannot overstate how grateful I am for that.
The deciding difference between the two: iOS decides when to run on its own and your job is to maximize the chances that you get a slot, while Android lets the developer specify conditions and frequency with reasonable precision. With that frame in mind, we can move into the code.
iOS: a production-ready BGTaskScheduler setup
A Rork-generated Info.plist typically does not include any background-mode configuration by default, so first install expo-task-manager and expo-background-fetch, then declare background modes in app.json.
A subtle thing to watch for: the identifier you register in BGTaskSchedulerPermittedIdentifiers must match exactly what you pass to BGTaskScheduler.shared.register in code. I once spent two days hunting a "the task isn't firing" bug that turned out to be a typo — net.rorklab.refresh versus net.rorklab.app.refresh. Define identifiers as constants in a shared file like constants/background.ts and import them on both sides.
While we are on the topic of identifier hygiene, plan ahead for the case where you want to retire an identifier. Once you ship an identifier in BGTaskSchedulerPermittedIdentifiers, removing it without a transition path means existing installs will silently fail to register the new identifier — they were granted permission for the old one. The cleanest pattern is to never delete identifiers, only deprecate them. Keep the deprecated identifier in the plist and unregister it explicitly in code on app launch. New installs and updated installs both end up in the right state.
A practical implication here: pick identifiers that are stable and descriptive of the work category, not the current implementation. Use net.rorklab.daily-sync, not net.rorklab.fetch-feed-v2. The latter looks tidy at first but you will regret it when v3 arrives and you need to coexist for a transition period.
In JavaScript, define a task with expo-task-manager and register it with BackgroundFetch.
// background-refresh.tsimport * as TaskManager from "expo-task-manager";import * as BackgroundFetch from "expo-background-fetch";import { fetchLatestFeed } from "./api/feed";import { saveToCache } from "./store/cache";export const REFRESH_TASK = "net.rorklab.refresh";TaskManager.defineTask(REFRESH_TASK, async () => { const startedAt = Date.now(); try { const feed = await fetchLatestFeed({ limit: 20, since: "auto" }); await saveToCache("home_feed", feed); // Expected output: short completion log like "[refresh] 18 items in 1.2s" console.log(`[refresh] ${feed.length} items in ${(Date.now() - startedAt) / 1000}s`); return BackgroundFetch.BackgroundFetchResult.NewData; } catch (error) { console.warn("[refresh] failed", error); return BackgroundFetch.BackgroundFetchResult.Failed; }});export async function registerBackgroundRefresh() { const status = await BackgroundFetch.getStatusAsync(); if (status === BackgroundFetch.BackgroundFetchStatus.Restricted || status === BackgroundFetch.BackgroundFetchStatus.Denied) { return; } await BackgroundFetch.registerTaskAsync(REFRESH_TASK, { minimumInterval: 60 * 15, // iOS treats this only as a hint stopOnTerminate: false, startOnBoot: true, });}
Two mistakes are common here. First, treating minimumInterval as the actual firing cadence. It is only a hint. iOS decides on its own based on device state and the user's habits — typically two to four times a day on real devices, with a tendency to cluster just before the user usually opens the app. Second, forgetting to return one of NewData, NoData, or Failed. If you forget, the system marks your app as a poor citizen and gradually starves it. I once shipped an app that quietly returned undefined and watched the firing rate decay day by day before I caught it.
There is one more iOS-specific behavior worth understanding before moving on. The system gives priority to apps the user actually opens. If your app is never opened for a week, BGAppRefreshTask will fire much less often regardless of what your code asks for. The implication is that background refresh is most effective for apps that already see daily engagement — it deepens what you have, but it cannot create engagement on its own. For an app that only gets opened once a week, server-driven Silent Push and the user-initiated AppState refresh become more important than BGTaskScheduler. I have learned to choose which mechanisms to invest in based on the engagement profile of the specific app, rather than implementing all of them by default. Apps with five-day-a-week users get all three layers; apps with two-day-a-week users get Silent Push plus AppState, and BGTaskScheduler is wired up but treated as a bonus.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can move past the 'silent push doesn't arrive' wall by understanding APNS throttling and adopt a delivery design that actually reaches users in production
✦You'll get working code that bridges iOS BGTaskScheduler and Android WorkManager behind a single React Native interface — ready to drop into your Rork app
✦You can build a 'background-recovery funnel' that brings back roughly 30% of users who would otherwise drift away — using mechanisms that live entirely inside your app
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
iOS: why Silent Push doesn't arrive, and how APNS throttling works
"My Silent Push never arrives" is one of the most common questions I get. The cause is almost always APNS throttling. Apple states it openly — Silent Pushes are limited to roughly two or three per hour. Sending a hundred a day will not get a hundred through. If the device is in Low Power Mode or the app has not been used for a while, the throttle is even tighter.
Given this, the practical pattern in production is to combine three things.
First, use Silent Push only for high-priority data changes. A burst sync when ten chat messages have queued up. A change in payment status. Not "there's a new post in the timeline." If you spend your throttle on low-value events, the one message that actually mattered will be the one that gets dropped.
Second, always set apns-priority: 5. priority: 10 is not allowed for Silent Push. Here is the Node.js side.
// send-silent-push.tsimport jwt from "jsonwebtoken";const TEAM_ID = process.env.APNS_TEAM_ID!;const KEY_ID = process.env.APNS_KEY_ID!;const KEY = process.env.APNS_AUTH_KEY!; // contents of the .p8 fileconst TOPIC = "net.rorklab.app";function makeToken() { return jwt.sign({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) }, KEY, { algorithm: "ES256", header: { alg: "ES256", kid: KEY_ID }, });}export async function sendSilentPush(deviceToken: string, payload: object) { const url = `https://api.push.apple.com/3/device/${deviceToken}`; const res = await fetch(url, { method: "POST", headers: { authorization: `bearer ${makeToken()}`, "apns-topic": TOPIC, "apns-push-type": "background", "apns-priority": "5", "apns-expiration": String(Math.floor(Date.now() / 1000) + 60 * 60), "content-type": "application/json", }, body: JSON.stringify({ aps: { "content-available": 1 }, ...payload }), }); if (!res.ok) { // Throttling can also surface as a 200 with reason: "TopicDisallowed", not just 429 console.warn("[apns]", res.status, await res.text()); } return res.status;}
Third, assume Silent Push will not arrive, and run BGTaskScheduler as a fallback. Silent Push for instant updates when it works, BGTaskScheduler for the few opportunistic syncs each day when it does not. Add one final layer: hook AppState's transition to active and force a refresh the moment the user opens the app. With these three layers in place, the perceived freshness of the app jumps noticeably.
After moving to that three-layer pattern in my own apps, the stream of low-rated reviews that boiled down to "the data was old when I came back" thinned out visibly. Users expect the app to always be current, and only after stacking these mechanisms behind the scenes does that expectation get met. It is a sense you only develop after running the app — it is not in any documentation.
A subtle but very practical detail: the device token is not stable forever. APNS will quietly invalidate tokens when the app is uninstalled, when the user wipes the device, or when iOS reissues for any reason. Your sender needs to handle the BadDeviceToken and Unregistered responses correctly, otherwise you accumulate dead tokens and your effective delivery rate keeps falling without your noticing. I store every token with a last_validated_at field, and a daily background job removes any token that has come back as unregistered three times in a row. Without that hygiene, after a year of operation, ten to fifteen percent of your token database can be useless ghosts, and you will keep wondering why "delivery looks fine" but engagement is not moving. This is one of those mistakes that does not show up in any tutorial but quietly costs you for months.
Another non-obvious aspect is that Silent Push and user-visible push share the same throttle budget on iOS. If your app already sends a lot of visible notifications, the headroom for Silent Push shrinks accordingly. I have seen apps where adding a chatty Silent Push pipeline broke their carefully tuned visible-notification deliverability. Treat your APNS budget as one shared resource, and prioritize accordingly.
Android: WorkManager and life under Doze
On Android, build around WorkManager. In the Expo world, expo-background-fetch wraps WorkManager for you, but for production-quality apps I recommend writing a thin native module of your own through Expo Modules so you can control the details. A minimal Worker looks like this.
// android/app/src/main/java/.../RefreshWorker.ktimport android.content.Contextimport androidx.work.CoroutineWorkerimport androidx.work.WorkerParametersclass RefreshWorker( context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { return try { val ok = ApiClient.fetchLatestFeed() if (ok) Result.success() else Result.retry() } catch (e: Exception) { // Recoverable failures (transient network errors) should retry if (runAttemptCount < 3) Result.retry() else Result.failure() } }}
The scheduling side looks like this.
// android/app/src/main/java/.../scheduleRefresh.ktimport androidx.work.*import java.util.concurrent.TimeUnitfun scheduleRefresh(context: Context) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val request = PeriodicWorkRequestBuilder<RefreshWorker>( 15, TimeUnit.MINUTES // 15 minutes is the minimum; you cannot go shorter ) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "rorklab.refresh", ExistingPeriodicWorkPolicy.KEEP, request )}// Expected outcome: WorkManager's DB has "rorklab.refresh" registered as ENQUEUED
The key to surviving Doze is choosing well between ExpeditedWorkRequest and a Foreground Service. Use ExpeditedWorkRequest for things that must run now, like a payment-status confirmation. Use a Foreground Service for long syncs and show the user a notification while it runs. Android is hostile to long-running invisible work, and putting that work somewhere visible turns out to be the path that survives. Note that PeriodicWorkRequest can be delayed by up to an hour under Doze; do not assume a true fifteen-minute cadence. "Roughly once between eight and thirty minutes" is closer to reality.
Manufacturer-specific battery optimizations — Huawei's EMUI, Xiaomi's MIUI, Samsung's Device Care — sometimes kill even WorkManager. If you target Japan only, this rarely matters. For global apps, consult something like Don't Kill My App and consider asking the user to grant ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission for your app.
I want to add a few things about WorkManager observability. WorkManager keeps a local SQLite database of all enqueued and completed work, accessible through WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData("rorklab.refresh"). During development, observe this stream and confirm that work is actually transitioning from ENQUEUED to RUNNING to SUCCEEDED. The most common failure mode I see is that work stays ENQUEUED indefinitely because the constraints are too strict — for instance, requiring setRequiresDeviceIdle(true) will essentially never fire on a phone that is in the user's pocket. Always start with relaxed constraints and tighten only when you have data showing the loose version works.
Also, do not call enqueueUniquePeriodicWork on every app launch without a guard. Each enqueue resets the accounting and pushes the next firing window further out. Use KEEP policy as shown above, and check whether work is already scheduled before re-enqueueing. I once had an app where users who opened it many times a day were seeing fewer background refreshes than users who opened it once a day, because every launch was implicitly resetting the schedule. That kind of bug is hard to find without observability.
A unified bridge from React Native to both OSes
With the platform-specific implementations in place, the most important thing when you call into them from Rork-generated React Native code is to build a unified interface. Below is a simplified version of a pattern I use in production.
With this in place, the UI layer just calls Background.registerRefresh(). Drawing the line so that the bridge absorbs OS differences and the upper code stays OS-agnostic is what lets Rork-generated code and your own logic coexist for a long time. A year into running the app, when you decide to swap the Android implementation, only the bridge changes — the UI never has to be touched.
One discipline I would add to the bridge pattern: every method should have a clearly defined "what happens if the OS refuses." iOS will refuse to register if the user has turned off Background App Refresh in Settings. Android will refuse if the app is on a manufacturer's aggressive battery list. Your bridge should expose getStatus() returning something like granted | restricted | denied | unknown, so that the UI layer can decide whether to show a "please enable Background App Refresh" hint card. Without that exposed status, you have a UI that silently does nothing and a developer who thinks "everything is wired up." Make the failure visible.
Designing the cadence: less is more than you think
This section is more about thinking than coding. Most developers reach for "the highest possible cadence" when they design background refresh. In my experience, the opposite is more reliable: start at the lowest cadence and only raise it when something forces you to.
There are three reasons. First, the higher you set the cadence, the more the OS treats your app as wasteful and starves it. Registering at fifteen minutes but only running once an hour under Doze is the result of being throttled. Second, you spend the user's battery and data; both eventually surface in app reviews. Third, server costs scale silently. With a million MAU and a full refresh every fifteen minutes, your monthly API costs can double or triple without anyone noticing for weeks.
What I do is decide cadence dynamically based on each user's last-active time. Users who opened in the last twenty-four hours get hourly syncs. Three-to-seven-day inactives get daily. Anything older gets weekly. That single change cut server load by more than half, and users barely noticed. Killing the "everyone gets the same cadence" assumption was the single biggest improvement I made to operational quality.
There is a related decision about what data you actually refresh. A common temptation is to refresh "the home feed and the user profile and the messages and the notifications and the cart" every time, because each of them might have changed. Resist that. The most expensive thing in mobile background work is not CPU — it is bandwidth and battery, both of which scale linearly with what you fetch. Pick the one or two pieces of data that the user will actually see in the first three seconds after opening, and refresh only those. Everything else can wait until they tap into that screen. This is the principle of "refresh the entrance, not the entire house." When I retrofitted this into an existing app, I expected a small improvement and got a large one — battery complaints fell by roughly forty percent in a month because the app was no longer pulling everything every time it had a slot.
Another principle worth naming: do not chain refreshes. If your background task fetches the feed, do not have it then fetch user details, then fetch unread counts, then fetch the cart. Each of those is a separate decision about whether the user will actually see the updated value. Chained refreshes hide hidden cost and make timing analysis nearly impossible because the duration of one task balloons unpredictably. Keep tasks atomic and let the user's next session do the rest.
Three production anti-patterns I learned the hard way
Here are three concrete mistakes I made before I knew better.
Anti-pattern 1: full API calls on every background refresh
In my first app I wrote a "fetch everything again" implementation each time we entered the background. It was fine on Wi-Fi, but users on 4G complained that "data usage explodes even when I'm not opening the app." I now uniformly use If-Modified-Since or a since=cursor style of differential fetch. Differential fetching is a bit more code, but it dramatically reduces both server load and user data — well worth building in from day one.
Anti-pattern 2: burning battery in the background
I ran video-thumbnail generation inside BGProcessingTask. The app started showing up high in users' Battery Usage screen, and the OS responded by throttling background execution. Heavy work either belongs to time the user is in the app, or has to declare requiresExternalPower: true so it only runs while charging. Putting CPU-intensive work where it is visible — that is a design philosophy that pays off.
Anti-pattern 3: forgetting to refresh the UI after Silent Push
A bug where Silent Push wrote new data into the cache but, when the user next opened the app, the old cached state was still on screen. The fix was to subscribe to AppState going active and re-render the UI when the cache had changed. Separating "writing data" from "updating the UI" is one of those obvious-in-hindsight design decisions that busy indie developers tend to overlook.
Anti-pattern 4: ignoring user state when scheduling
For a long time I scheduled refreshes uniformly regardless of whether the user was a paying subscriber, a free user, or someone who had opted out of notifications. The result was that I burned battery and bandwidth on users who would never notice, while running the same cadence for users who would have benefited from more. After segmenting cadence by user state — paying subscribers got more frequent refreshes, free users got the median, and notification-opt-out users got the lowest cadence — server costs dropped about thirty percent and the high-engagement users actually saw better latency on launch. Cadence is not just a technical knob; it is a product decision that should reflect what each user is paying back into the relationship.
These pitfalls are not in the docs. I have tried to put words around the risks that hide between the lines of any official guide. I would be glad if you avoided the holes I fell into.
Measuring success: track your background success rate
Now the most overlooked part of all this. Background processing cannot live on "it should be running." Build success-rate measurement into the system from day one.
What I do is write the start, end, and failure of every background task into a local SQLite table, and flush them to the backend in a batch the next time the app launches. The minimum useful structure looks like this.
// background-tracking.tsimport { db } from "./db";export async function trackBackgroundRun( task: string, status: "success" | "failed" | "noData", durationMs: number) { await db.runAsync( "INSERT INTO bg_runs (task, status, duration_ms, ran_at) VALUES (?, ?, ?, ?)", [task, status, durationMs, Date.now()] );}export async function flushBackgroundLogs() { const rows = await db.getAllAsync("SELECT * FROM bg_runs WHERE sent = 0"); if (!rows.length) return; await fetch("/api/bg-runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(rows), }); await db.runAsync("UPDATE bg_runs SET sent = 1 WHERE sent = 0");}// Expected outcome: a dashboard line like "success rate 87.4%" / "3,212 runs in 24h"
Once you can see the numbers, the truth surfaces. In one of my apps, the background success rate started at fifty-two percent. As I reduced the dependence on Silent Push and added BGTaskScheduler in parallel, it climbed into the eighties. After loosening the Android Constraints, sixty-eight percent moved to seventy-eight. With that concrete number in hand, I could finally say "this part is worth optimizing." Replacing "it should work" with "it works seventy-eight percent of the time" is what turns the operation of an app into something you can think about scientifically.
The background-recovery funnel — bringing drifting users back
These mechanisms are not the goal in themselves. The real goal is to lift the experience of users who come back, by having something fresh waiting, and through that, to reduce churn. I think of this as the "background-recovery funnel" and design it deliberately.
The funnel has four stages. Stage one: users who opened today. Stage two: users who haven't opened for two or three days. Stage three: a week away. Stage four: more than two weeks. Each stage uses background processing differently. For stage one, simply syncing the latest delta in silence is enough. For stage two and beyond, the design becomes more deliberate — Silent Push to trigger re-engagement, or a local notification scheduled by the app itself that quietly says "there are N new items you haven't seen."
In one of my apps, simply scheduling a local notification for the next morning — entirely in the background, no server involvement — for stage-two users raised the re-open rate by twenty-two percent over three weeks. There is something quite freeing about realizing that re-engagement does not have to live on the server. The app itself, working quietly while it is closed, is enough to bring users back. That was the moment this whole subject started to feel important to me.
There is also an instrumentation question worth asking before you build the funnel. How do you actually know a user has drifted? The naive answer is "we have not seen a session in N days," but that misses people who opened the app briefly without doing anything meaningful. I prefer to define the funnel against meaningful events — a chat sent, a session over thirty seconds, an in-app purchase, a content view. Stage transitions then track meaningful inactivity rather than raw open events. The numbers move slower this way, but the optimization decisions you make on top of them are far more honest.
Local notifications scheduled by background tasks deserve a specific note. Schedule them with a slight randomization in the timing — say, plus or minus fifteen minutes from your nominal target — so that you do not have a thousand users receiving "you have unread messages" at the exact same minute. Apps that fire identical notifications to a million users at 9:00 AM exactly tend to get reported as spammy by users and lose a non-trivial fraction of their notification permission. Spreading the firing across a window keeps the notifications feeling personal and protects your permission base.
What is easy to forget when you design the funnel is the value of holding back. If you push silent updates and notifications even at stages three and four, what you have is not a thoughtful app but a noisy one. I have made that mistake — pushing too aggressively and watching the notification opt-out rate spike in a single week. Re-engagement should start at "a frequency that does not interfere with the user's life," and you tune it gently while watching the data. Honestly, less a technical question than a question of imagining the person on the other side.
The "quality of an app while it is closed" is one of the easiest places for a developer to cut corners — and one of the places that most shapes how users feel about the product. If you take one thing from this article into your own Rork app, let it be this: put a single number on your dashboard that shows the background-execution success rate over the last twenty-four hours. Once you can see it, you will know what to fix.
A small additional habit I would recommend: revisit your background-refresh implementation once a quarter, even when nothing seems wrong. OS-level changes — new battery-optimization features, new APNS behavior, new throttling thresholds — get rolled out quietly with point releases and can change your effective success rate without any code change on your side. A quarterly checkup that compares this quarter's success-rate dashboard with the previous one is enough to catch drift before it becomes a user-facing problem. I keep this in my calendar as a recurring fifteen-minute task. Most quarters everything is fine. The quarters when something has shifted, fifteen minutes saves me a month of confused user reports.
The other discipline that pays for itself is keeping a brief, dated changelog of every background-refresh-related change. When success rate drops, the first question is always "what changed?" If you can answer it from a changelog, you save hours. If you have to dig through git history and try to remember what you were doing in February, you will lose half a day. Treat background refresh as production infrastructure, even though it does not feel like infrastructure — because it absolutely is.
I am still learning. What I have written here will need to be rewritten when next year's OS releases change the rules. Even so, if some part of this article makes the closed-time of your app a little richer, that would mean a lot. Thank you for reading to the end.
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.