The iOS Screen Time API in 2026: what Apple’s docs don’t tell you

FamilyControls, DeviceActivity and shield extensions, from shipping an app blocker. By Thomas Willheim · September 30, 2026

I spent August and September 2026 shipping an app blocker on iOS. It uses the Screen Time frameworks (FamilyControls, DeviceActivity, ManagedSettings and ManagedSettingsUI) from React Native, through Expo SDK 57 and the react-native-device-activity library (0.6.1). Apple’s documentation tells you what each type is. It says very little about how they behave on a real phone, overnight, on iOS 26.

This is the list I wish I’d had. Behavior I saw on a device but can’t find documented is labeled observed; it may not hold on every iOS version. Items specific to the React Native library are labeled library; skip those if you write Swift directly.

1. The moving parts

“The Screen Time API” is four frameworks and, in practice, three app extensions:

The extensions are the point. My app is usually suspended or killed when a user runs out of time, so the monitor extension is what puts the shield back up. Design as if your app process will never be there when things happen.

2. Entitlements: one request per bundle ID

The Family Controls (Distribution) entitlement is approved per bundle ID. With three extensions, that’s four requests. Mine were:

Once they’re approved, enable the Family Controls (Distribution) capability on each App ID in the developer portal, then create one App Group and attach it to all four. Miss one and that target won’t provision. Approvals aren’t instant, so file all four requests on the first day, before you’ve written much code.

Every extension’s entitlements file ends up the same:

<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
  <string>group.com.honorfit.blocker</string>
</array>

On Expo/EAS, the build service only creates provisioning profiles for extensions it knows about. I declare each target, with its entitlements, under extra.eas.build.experimental.ios.appExtensions in app.json:

"appExtensions": [
  {
    "bundleIdentifier": "com.honorfit.blocker.ShieldConfiguration",
    "targetName": "ShieldConfiguration",
    "entitlements": {
      "com.apple.developer.family-controls": true,
      "com.apple.security.application-groups": ["group.com.honorfit.blocker"]
    }
  }
  // ...same for ShieldAction and ActivityMonitorExtension
]

3. The App Group is the only channel

Your app and its extensions are separate processes that are almost never running at the same time. The one thing they share is the App Group container, usually as UserDefaults(suiteName:). Everything the extensions need (the stored selection, the shield text, what to do when a threshold fires) has to be written there ahead of time.

react-native-device-activity takes this all the way. The app writes a list of “actions” under a key like actions_for_{activity}_{callback}_{event}, and the monitor extension looks them up when the callback fires. A useful consequence: you can change what an already-running monitor does without restarting it. The extensions also call CFPreferencesAppSynchronize before reading, so they see the app’s latest writes:

let appGroup = Bundle.main.object(
  forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)

// in each callback:
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)
if let actions = userDefaults?.array(forKey: triggeredBy) { /* run them */ }

4. Tokens are opaque, and the picker

The picker hands your app ApplicationTokens, ActivityCategoryTokens and WebDomainTokens, and nothing else. By design, the app can’t turn a token back into “Instagram”. What it can do is count them, so my blocklist screen shows “3 apps, 1 category”. That’s the whole privacy model, and it’s a good one: my app literally doesn’t know what you blocked.

The shield extension is different. There, the system passes you an Application for the app being shielded, and I use its localizedDisplayName in the shield text. The app itself only ever sees tokens.

Two practical notes:

<DeviceActivitySelectionSheetViewPersisted
  familyActivitySelectionId="distracting-apps"
  includeEntireCategory={false}   // must be present, or nothing preloads
  onSelectionChange={(e) => setCounts(e.nativeEvent)}
  onDismissRequest={() => setPickerVisible(false)}
/>

library One more: the library’s blockSelection adds tokens to a persisted blocklist. After the user edits their selection, the removed apps stay shielded. I call resetBlocks() first and then re-block the current selection every time.

5. Metering usage with threshold events

HonorFit gives you a balance of minutes that only drains while a blocked app is on screen. DeviceActivity has no “tell me how long they’ve used it” call; it has threshold events that fire once cumulative usage of a selection reaches a duration. So I register one event per 15 seconds of usage, named in minutes: used-0.25, used-0.5, … up to the balance. Quarters are exact in binary floating point, so the names round-trip through Number(). Under the library, this is the plain Apple API:

let schedule = DeviceActivitySchedule(
  intervalStart: DateComponents(hour: 9, minute: 45, second: 0),  // see section 6
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
  repeats: false
)
let event = DeviceActivityEvent(
  applications: selection.applicationTokens,
  categories: selection.categoryTokens,
  webDomains: selection.webDomainTokens,
  threshold: DateComponents(minute: 0, second: 15),
  includesPastActivity: false          // iOS 17.4+
)
try DeviceActivityCenter().startMonitoring(
  DeviceActivityName("honorfit-distracting-apps-1790000000000"),
  during: schedule,
  events: [DeviceActivityEvent.Name("used-0.25"): event]  // ...one per step
)

includesPastActivity: false (the default) means usage from before the events were registered doesn’t count. That matters for the schedule trick in the next section.

When the final event fires, the monitor extension re-applies the shield and posts a “time’s up” notification. Each 15-second event updates one notification with a fixed identifier, so it replaces in place and works as a rough live countdown. interruptionLevel = .passive delivers it quietly to Notification Center with no banner.

library Don’t put underscores in event or activity names. The library persists fired events under events_{activity}_{callback}_{eventName} and parses them back by splitting on _. An event called used_7 comes back as used, and nothing ever matches. The symptom was a balance that never went down.

6. Schedules don’t start when you think

If your threshold events silently never fire, it’s probably this.

“Now” is already in the past

observed A non-repeating DeviceActivitySchedule is matched against date components, and the monitor runs the next interval that matches. If you pass the current wall-clock time as intervalStart, then by the time the framework evaluates it, that moment has passed. The next match is tomorrow, and today nothing fires: no thresholds, no callbacks, no error. The fix is to backdate the start so “now” is inside the interval:

const now = new Date();
const backdated = new Date(now.getTime() - 15 * 60_000);
const intervalStart =
  backdated.getDate() === now.getDate()
    ? { hour: backdated.getHours(), minute: backdated.getMinutes(), second: backdated.getSeconds() }
    : { hour: 0, minute: 0, second: 0 };   // don't wrap past midnight

await startMonitoring(activityName,
  { intervalStart, intervalEnd: { hour: 23, minute: 59, second: 59 }, repeats: false },
  events);

Backdating can’t charge the user for earlier scrolling, because of includesPastActivity: false. It also keeps the interval above Apple’s minimum: schedules shorter than 15 minutes are rejected, which bites anyone starting a session just before midnight.

A schedule that contains “now” starts immediately

observed The flip side: a 00:00–23:59:59 schedule contains the current time, so it starts now, not at the next midnight. Two ways I found to book tomorrow:

observed Don’t use full year/month/day components in a schedule to target a specific date. Extension callbacks became unreliable when I tried, which matches reports on the Apple Developer Forums (thread 729841). Stick to hour/minute/second.

A caveat: these midnight behaviors only show up on a device, overnight. I’ve built guards around them but haven’t instrumented as many real midnights as I’d like, so treat this section as “what I saw”, not a spec.

7. stopMonitoring fires intervalDidEnd

observed Calling stopMonitoring, or calling startMonitoring with the name of a monitor that’s already running, makes iOS call intervalDidEnd on your extension as if the schedule had ended naturally. If your intervalDidEnd re-shields as a safety net (mine does, so a monitor can’t expire and leave apps unblocked), then every time the app stops a monitor, the extension briefly shields everything.

A user reported the symptom: Spotify paused every time they opened HonorFit. Stopping the monitor made the extension shield the selection for a moment, which suspended Spotify, before the app lifted the shields again.

Two fixes, both needed. First, delete the extension’s actions before stopping, so there’s nothing for it to run:

function stopActivities(names: string[]): void {
  for (const name of names) RNDA.cleanUpAfterActivity(name); // clear actions first
  RNDA.stopMonitoring(names);                                // then stop
}

Second, give every intervalDidEnd and intervalDidStart action a “not before” timestamp at the schedule’s real boundary, minus a minute of slack. The extension checks it before running anything:

// TypeScript: register the safety net
RNDA.configureActions({
  activityName,
  callbackName: 'intervalDidEnd',
  actions: [{
    type: 'blockSelection',
    familyActivitySelectionId: 'distracting-apps',
    neverTriggerBefore: new Date(intervalEndMs - 60_000),
  }],
});

// Swift, in the DeviceActivityMonitor extension
if let neverTriggerBefore = neverTriggerBefore {
  let now = Date().timeIntervalSince1970 * 1000
  if now < neverTriggerBefore { return false }   // skip this action
}

In plain Swift: store the genuine end time in the App Group and have intervalDidEnd bail out when called early.

8. iOS 26 phantom thresholds

On iOS 26.x, eventDidReachThreshold sometimes fires with no real usage at all: right after scheduling, or when a phone that sat locked for a while is picked up or plugged in. Apple has acknowledged this in developer forum threads (809410, 811305, 814559). It was reportedly fixed in a 26.5 beta, but people kept reporting it afterwards. In my app the symptom was “out of time” after the phone had been in a pocket, because the extension re-shielded and the balance drained to zero.

You can’t stop the event from firing, but you can refuse to believe it. Two facts about genuine thresholds do most of the work:

  1. Usage can’t outrun the wall clock. used-7 can’t legitimately fire less than 7 minutes after the monitor was armed. Every threshold action carries neverTriggerBefore = armedAt + N minutes − 5s, so the extension skips impossible-early fires.
  2. Real usage arrives incrementally; phantoms arrive as a burst. When the app reads back fired events, it treats it as a phantom when the lowest and highest thresholds fired within about 3 seconds of each other but span a minute or more of usage.
// Guard 1: drop thresholds that fired before they could have been reached
if (at < startedAt + minutes * 60_000 - 5_000) continue;

// Guard 2: lowest and highest fired in the same instant, a minute or more apart
if (highest.minutes - lowest.minutes >= 1 &&
    Math.abs(highest.at - lowest.at) <= 3_000) {
  return { usedMinutes: 0, partialMs: 0 };   // phantom: ignore the lot
}

Guard 2 runs in the app, not the extension, so a phantom can still put the shield up. The balance survives, though, and the next time the app is opened it lifts the shield again. Doing burst detection inside the extension would need custom Swift, which I haven’t written yet.

observed A related problem goes the other way: thresholds that should fire and don’t. With in-and-out usage on iOS 26, I saw a fresh monitor’s first 15-second threshold fire on time, and later ones get coalesced or dropped entirely (used-0.25 fired, used-0.5 and up never did). My workaround is to treat any monitor that has already metered usage as stale and re-arm a fresh one, over the remaining balance, every time the app goes to the background. That forgives up to 15 seconds of usage per restart, which I’m fine with.

9. A shield can’t open your app

The obvious design is a shield button that says “Earn more time” and opens your app. There’s no supported way to do it; Apple DTS says there’s no API for a shield extension to launch its parent app. A ShieldActionDelegate answers a button press with a ShieldActionResponse (.none, .close or .defer), and none of those opens anything.

observed The workaround you’ll find (NSExtensionContext().open(url) from the ShieldAction extension, which the library exposes as openUrl) did nothing on current iOS when I tested it on a device in September 2026. The button just closed the shield. Posting a notification from the button tap was unreliable for me too.

What I ship instead: the shield has a single “Close” button, and the ShieldConfiguration extension posts a local notification whenever the shield is displayed. The user taps the banner and lands in the app. The app writes the notification payload to the App Group ahead of time, and dismisses the notification when it comes to the foreground. iOS may ask for the shield configuration several times per display, so the post is debounced:

func postShieldShownNotification(placeholders: [String: String?]) {
  guard let config = userDefaults?.dictionary(forKey: "honorfitShieldShownNotification"),
        let payload = config["payload"] as? [String: Any] else { return }

  let minInterval = config["minIntervalSeconds"] as? Double ?? 30
  let now = Date().timeIntervalSince1970
  let lastSentAt = userDefaults?.double(forKey: "honorfitShieldShownNotificationLastSentAt") ?? 0
  if now - lastSentAt < minInterval { return }
  userDefaults?.set(now, forKey: "honorfitShieldShownNotificationLastSentAt")

  // builds a UNMutableNotificationContent and adds a UNNotificationRequest
  // with a fixed identifier, so repeats replace rather than stack
  sendNotification(contents: payload, placeholders: placeholders)
}

class ShieldConfigurationExtension: ShieldConfigurationDataSource {
  override func configuration(shielding application: Application) -> ShieldConfiguration {
    postShieldShownNotification(placeholders: [
      "applicationOrDomainDisplayName": application.localizedDisplayName
    ])
    return buildShield(/* title, subtitle, colors from the App Group */)
  }
  // ...same hook in the category and web-domain overloads
}

It’s a hack, and it depends on the user allowing notifications, but it’s the only route into the app that worked reliably for me.

10. Keeping custom extensions through prebuild

library If you use Expo, expo prebuild runs the react-native-device-activity config plugin, which by default copies the library’s stock extension sources into ./targets on every run. My customized ShieldConfiguration was silently replaced. The fix is one plugin option:

["react-native-device-activity", {
  "appleTeamId": "XXXXXXXXXX",
  "appGroup": "group.com.honorfit.blocker",
  "copyToTargetFolder": false
}]

The cost: when you upgrade the library, diff its targets/ and shared Swift against yours by hand and re-apply your changes.

11. Xcode 26, the Simulator, and testing

12. And on Android

For contrast, Android has no sanctioned equivalent, so you build it yourself. HonorFit’s Android blocker is a foreground service (type specialUse, since app blocking fits none of the listed types) that polls UsageStatsManager once a second while the screen is on to learn which app is in front. When a blocked app is up and the balance is zero, it draws a TYPE_APPLICATION_OVERLAY lock screen over it (“Display over other apps”). A boot receiver and a watchdog alarm restart the service after reboots, app updates, and aggressive OEM battery managers.

The trade-offs point the opposite way from iOS. You see real package names, so privacy is a promise you keep rather than one the OS enforces. A blocked app can be visible for up to one poll interval, and it keeps running under the overlay. And you’ll write Play Console declarations for the foreground service type, the overlay permission and package visibility. I deliberately avoided an AccessibilityService, which gets the strictest review of all.

13. Checklist

SymptomLikely causeFix
Provisioning fails for one targetEntitlement or App Group missing on that bundle IDRequest Family Controls (Distribution) and attach the App Group for every bundle ID
No threshold events, everintervalStart = now, so the monitor is booked for tomorrowBackdate the start; keep the interval ≥ 15 min
Monitor meant for tomorrow runs todaySchedule contains the current timeExpired-today [00:00, T) schedule, or repeats: true with guarded actions
Apps flash-shield when your app opensstopMonitoring fires intervalDidEndClear actions before stopping; guard with a not-before time
“Out of time” after the phone sat idleiOS 26 phantom thresholdsWall-clock guard plus burst filter
Later thresholds never fireDropped/coalesced under intermittent useRe-arm a fresh monitor on each background
Shield button won’t open the appNo supported APIPost a notification from ShieldConfiguration on display
Fired events don’t match (RN)Underscore in event nameUse used-7, not used_7
Picker opens empty (RN)includeEntireCategory prop missingPass it, with any value

14. Quick answers

Do I need the Family Controls entitlement for every extension?

Yes. It’s granted per bundle ID. The app plus a DeviceActivityMonitor, a ShieldConfiguration and a ShieldAction extension is four approvals, and each App ID also needs the shared App Group.

Why is DeviceActivity eventDidReachThreshold not firing?

For me it was usually a non-repeating schedule starting at “now”, which gets booked for tomorrow. Backdate intervalStart. On iOS 26, under in-and-out usage, later sub-minute thresholds also got dropped; re-arming a fresh monitor helped.

Can a ShieldAction or ShieldConfiguration extension open the parent app?

Not with a supported API, and the NSExtensionContext workaround did nothing on current iOS in my testing. A local notification posted from ShieldConfiguration when the shield is displayed is what I ship.

Can I test the Screen Time API in the iOS Simulator?

Partly. The prompt and picker appear on iOS 26 simulators, but authorization didn’t complete, the picker had nothing to list, and the monitor extension never ran. Use a device.

15. Why I needed all this

I built HonorFit, an app blocker where you earn screen time by doing pushups. It works on the honor system: you type in your reps, and there’s no camera, no AI and no network calls. Everything above is what it took to make “your earned minutes only drain while a blocked app is on screen” work on an iPhone. If you want to see it running, it’s on the App Store and Google Play. If you’ve hit a Screen Time behavior that isn’t here, or found that something above is wrong on a newer iOS, I’d like to hear about it.

Keep reading