Apple's Screen Time API: Everything I Learned the Hard Way

Richard Andrews
Richard Andrews ·9 min read
X LinkedIn
Shattered iPhone screen with three glowing purple lock fragments — representing the triple-blocking architecture needed for Apple's Screen Time API

I spent 97 days building Habit Doom, an iOS app that locks your distracting apps until you complete your daily habits. The hardest part was not the UI, the business logic, or the App Store review process. It was Apple's Screen Time API.

This is everything I learned — the stuff Apple's documentation does not tell you, the behaviors that will break your app silently, and the architecture I ended up with after 21 bugs nearly killed the project.

If you are building anything that locks or monitors apps on iOS, this might save you weeks.

The Three Frameworks

Apple's "Screen Time API" is actually three separate frameworks that work together:

FamilyControls — handles authorization. Your app requests permission from the user to monitor and restrict their device activity. This is the gatekeeper.

ManagedSettings — handles the actual blocking. You create a ManagedSettingsStore and set store.shield.applications to a set of app tokens. Those apps are now locked at the OS level. Users see a shield screen when they try to open them.

DeviceActivity — handles scheduling. You can set up monitoring schedules that trigger your code when intervals start or end. This is how you schedule re-blocking events.

The documentation for all three is thin. Some classes have one-line descriptions. Some behaviors are not documented at all. Here is what I learned the hard way.

Lesson 1: One Blocking Mechanism Is Never Enough

This is the most important lesson in this entire post.

If your app blocks other apps, the blocking has to survive the user closing your app, force-quitting it, restarting their phone, or ignoring it for 12 hours. A single blocking mechanism cannot handle all of these scenarios.

I ended up with three independent systems that all enforce the same lock:

Shield Extension — runs synchronously whenever the user tries to open a blocked app. It checks if it is a new day or if the unlock timer has expired, and re-blocks if needed. This is your first line of defense because it fires before the app can even open.

DeviceActivity Monitor — runs on a schedule in the background. It proactively checks whether the unlock timer has expired and re-blocks apps even when the user is not trying to open them.

Main App — when your app comes to the foreground, it immediately checks the shared state and re-blocks if needed. This runs before the database initializes, before the UI renders — a synchronous check with zero async calls.

Why all three? Because each one covers the gaps of the others. The Shield Extension only fires when the user opens a blocked app. The DeviceActivity Monitor only fires on schedule (and schedules can be unreliable — more on that below). The Main App only fires when the user opens your app. Together, they cover every scenario.

Lesson 2: DeviceActivity Schedules Are Unreliable Beyond 45 Minutes

This one took weeks to figure out and there is no documentation about it anywhere.

If you schedule a DeviceActivity monitoring event for 90 minutes in the future, it might fire at 60 minutes. Or it might not fire at all. Apple's scheduling system simply is not reliable for events far in the future.

The workaround: chain shorter schedules together. Schedule checks at 15, 30, and 44 minutes. When each check fires, it looks at the shared state to see if the unlock timer has expired. If it has not expired yet, it chains the next check — scheduling another event 16 minutes out (or targeting 1 minute past the expected expiry if that is closer).

Why 44 minutes and not 45? Because you want to stay safely within the reliable window with a margin of error.

Why rotate through multiple schedule names? Because if two schedules try to use the same DeviceActivityName, the second one overwrites the first. Use rotating slot names like habitDoomReblockChain_0 through habitDoomReblockChain_4 so they do not clobber each other.

Lesson 3: startMonitoring() Has a Devastating Side Effect

When you call center.startMonitoring() for a DeviceActivityName that is already being monitored, it first calls stopMonitoring() internally. This triggers intervalDidEnd on your DeviceActivity Monitor extension.

If your intervalDidEnd handler re-blocks all apps (which is a reasonable thing for it to do), then calling startMonitoring() to update a schedule will re-block all apps as a side effect — even in the middle of an active unlock session.

I discovered this when users reported their apps getting locked at 1 AM for no reason. The cause: a scheduled re-check was calling startMonitoring() to chain the next check, which triggered intervalDidEnd, which re-blocked everything.

The fix: add a guard in your intervalDidEnd handler that checks whether the current unlock session is still active before re-blocking. Do not assume that intervalDidEnd means "the user's time is up."

Lesson 4: Extensions Cannot Share a Database

Your Shield Extension, DeviceActivity Monitor, and main app all run as separate processes. They cannot share a SwiftData or Core Data database.

The only reliable way to share state between them is UserDefaults via App Groups. This means manually serializing everything — app tokens, category tokens, web domain tokens, timestamps, flags — into UserDefaults keys.

Here is the minimum shared state I ended up needing:

  • isCurrentlyUnlocked — whether an unlock session is active
  • quotaEndTimestamp — when the unlock timer expires
  • globalSerializedTokens — the app tokens to re-block (base64 JSON)
  • globalSerializedCategoryTokens — same for categories
  • globalSerializedWebDomainTokens — same for web domains
  • lastShieldDayCheck — when the Shield Extension last checked for a new day

Serialize carefully. If the token data fails to decode and you write an empty set back to UserDefaults, you have permanently destroyed your blocking data. Always validate before overwriting.

Lesson 5: You Must Block Apps, Categories, AND Web Domains

When you apply a shield, you need to set three properties on your ManagedSettingsStore:

  • store.shield.applications — blocks individual apps
  • store.shield.applicationCategories — blocks app categories
  • store.shield.webDomains — blocks web domains

If you forget web domains, users can access Instagram through Safari. If you forget categories, entire groups of apps slip through. I missed this across three different extension files and had to fix it in all of them.

Lesson 6: The Stale Flag Problem

Your main app sets isCurrentlyUnlocked = true when the user starts an unlock session. It starts a timer to set it back to false when the session expires.

But if the user backgrounds your app, that timer stops. The flag stays true indefinitely. Your extensions read the flag and think apps should still be unlocked — even though the timestamp says the session expired 2 hours ago.

The fix: never trust a boolean flag alone. Always check the timestamp. If isCurrentlyUnlocked is true but quotaEndTimestamp is in the past, the session has expired regardless of what the flag says. Check both in every extension.

Lesson 7: Make Your First Check Synchronous

When your app comes to the foreground after a long background period, there is a race condition. The UI starts rendering, async tasks kick off, and the database begins initializing — all before you have had a chance to check whether the unlock session has expired.

The fix: run a synchronous, zero-async check before anything else. Before your SwiftData model container is created, before ContentView renders, read the UserDefaults state and re-block if needed. No await. No Task. Just a direct read and a direct write to ManagedSettingsStore.

This also means your re-blocking function cannot depend on the database. It has to work entirely from UserDefaults.

Lesson 8: Minute vs Second Precision

DeviceActivity uses DateComponents(hour:, minute:) for scheduling. Your unlock timestamps use TimeInterval (seconds since epoch).

An unlock session that expires at 10:30:45 will not be caught by a DeviceActivity check scheduled for 10:30. The check fires at 10:30:00, reads the timestamp, sees that 10:30:45 is still in the future, and does nothing. The next check might not come for 15 minutes.

The fix: when scheduling checks near the expected expiry, add a 1-minute buffer. Schedule for 10:31 or 10:32, not 10:30.

Lesson 9: Async Re-blocking Gets Suspended

If your re-blocking function uses async/await and has multiple await points, iOS can suspend the Task if the user switches to another app. The function stops mid-execution. Apps might be partially blocked — some locked, some not.

The fix: write a synchronous version of your re-blocking function that runs first, before any async work. It does not need to be perfect — it just needs to apply the shield immediately. The async version can clean up state afterward.

The Architecture That Actually Works

After 97 days, 21 bugs, and two full rewrites of the app, here is the architecture I would recommend to anyone building on the Screen Time API:

  1. Three independent blocking systems — Shield Extension, DeviceActivity Monitor, Main App. All three check and re-block independently.

  2. Shared state via App Groups UserDefaults — serialize everything carefully. Validate before overwriting. Always check timestamps alongside boolean flags.

  3. Chained DeviceActivity schedules — never schedule more than 44 minutes out. Chain checks that each schedule the next one.

  4. Synchronous first check on foreground — before database, before UI, before async. Read UserDefaults, re-block if expired.

  5. Block apps, categories, AND web domains — in every extension that applies a shield.

  6. Guard intervalDidEnd — do not assume it means the user's time is up. Check the actual state before re-blocking.

This is not elegant. It is defensive to the point of paranoia. But app locking is a feature where one failure destroys the user's trust in your entire product. Being paranoid is the right call.

One More Thing

The emulator lies. My app blocking worked perfectly in Xcode's simulator. It failed completely on a real device when Xcode was not attached. The debugging environment changes the behavior of background extensions.

Always test app blocking on a real device, disconnected from Xcode, with the app backgrounded or killed. That is the only environment that matches what your users will experience.

I hope this saves you some of the 97 days it took me. If you are building something with the Screen Time API and hit a wall, I have probably hit the same wall — feel free to reach out.

Habit Doom is the app I built with all of this. It locks your apps until your habits are done. Free on the App Store.

Frequently Asked Questions

Apple's Screen Time API consists of three frameworks: FamilyControls (authorization), ManagedSettings (applying app shields/blocks), and DeviceActivity (scheduling monitoring events). Together they allow apps to lock other apps at the operating system level. The API was originally designed for parental controls but can be used by any app with user authorization.
The documentation is minimal, the community is tiny (most apps using it are parental control apps), Stack Overflow coverage is sparse, and many behaviors are undocumented — like DeviceActivity schedules being unreliable beyond 45 minutes, or startMonitoring() triggering intervalDidEnd as a side effect. Most problems you hit will have no existing answers online.
No. When you apply a shield using ManagedSettingsStore, the lock is enforced at the operating system level. Users cannot bypass it by force-quitting your app, restarting their phone, or switching accounts. The only way to remove the shield is through your app's code.
Habit Doom is free to download and use. Core features including habit tracking, app blocking, and streaks work without paying. Premium features are available for $2.99/month, $19.99/year (with a 14-day free trial), or $34.99 for lifetime access. No ads. Download it from the App Store.

Keep Reading

Try Habit Doom

Lock your distracting apps. Complete your habits. Earn your screen time. It takes 30 seconds to set up.

AppleDownload Free
Habit DoomNo sign-in required
AppleDownload Free