FamilyControls in Production: The Edges

Richard Andrews
Richard Andrews ·11 min read
XX LinkedInLinkedIn
X LinkedIn

There is a tutorial-shaped layer of the Screen Time API: request FamilyControls authorization, show a FamilyActivityPicker, set store.shield.applications, watch an app refuse to open. That layer is covered in the introductory Screen Time API guide, which walks through the three frameworks and the triple-blocking architecture that keeps a lock alive through force-quits and restarts. Read that one first if you have never touched FamilyControls.

This post is the layer underneath. It is the set of things that only show up once the feature is in front of real users on real hardware: where the extensions run out of room, what the tokens refuse to tell you, the funnel hole that loses a third of your installs before they ever block anything, and the App Review and crash traps that have nothing to do with the docs and everything to do with production. These are the notes we wish someone had handed us.

3 sandboxed processesMain app, Shield Extension, DeviceActivity Monitor. They share nothing but App Group UserDefaults.

The extensions live in a closet

The headline fact about FamilyControls extensions is the one Apple never prints: the DeviceActivityMonitor and shield extensions run under a memory budget so small it shapes your whole architecture. The main app gets the normal generous footprint. The extensions get a closet.

This is not a number you tune around. It is a wall you design around. The rule we settled on: extensions do the absolute minimum and nothing else. Read the shared state out of App Group UserDefaults, decide block-or-unblock, write the shield, and get out. No database. No large image decode. No object graph. No model.

That last one was load-bearing for a later feature. When we built on-device photo verification with a 145 MB CLIP model, there was an obvious-sounding idea to run inference somewhere in the monitoring path. It is a non-starter. A model that needs hundreds of megabytes of working memory cannot live anywhere near an extension that gets a closet. The model loads only in the main app, gated behind the feature being on, and the extensions never touch it. The memory ceiling did not just rule out one approach, it drew the boundary of what the extensions are allowed to be: dumb, fast, and stateless.

The corollary is that the extensions cannot be trusted to do anything clever, so the cleverness has to be precomputed. The main app serializes everything an extension might need (the token sets, the timestamps, the flags) into UserDefaults ahead of time, and the extension just executes. If you find yourself wanting to do real work inside intervalDidStart or a shield action, that is the signal you have put logic in the wrong process.

Tokens that refuse to identify themselves

ApplicationToken, ActivityCategoryToken, and WebDomainToken are the values you collect from the picker and hand to ManagedSettingsStore. The thing the docs underplay is how aggressively opaque they are.

A token has no display name. No bundle identifier. No human-readable anything. The only way to turn a token back into something a person recognizes is to pass it into a SwiftUI Label, which the system resolves to the real icon and name at render time. You can show the user their blocked apps. You cannot, in your own code, learn that one of them is Instagram.

This is the privacy contract, and it is a good one: the app enforces a block on apps it is never told the identity of. But it has hard practical consequences that surprise people building their first FamilyControls app.

  • You cannot log a token usefully. It is an opaque blob, not a string. Crash reports and debug logs about "which apps are blocked" are off the table.
  • You cannot persist tokens server-side. Even if you wanted a backend, the tokens are scoped to your App Group and your device context. They do not travel.
  • You cannot share a token across teams or apps. A token minted in one app's authorization context is meaningless in another.

For us this fell out cleanly because Habit Doom has no backend at all, but if your mental model is "store the user's selection in my database and sync it," the API will quietly refuse and you will spend a day figuring out why your decoded tokens enforce nothing on the other device. The tokens are not data you own. They are capabilities the system lends you, valid only inside the sandbox that issued them.

One more sharp edge here: serialize the token sets carefully and validate before you overwrite. If a decode fails and you write an empty set back to shared storage as the "current" blocklist, you have not lost a debug log, you have silently disarmed the user's entire lock. Treat the persisted blocklist the way you would treat a payment record. Never overwrite it with the result of a decode you did not check.

A third of users grant permission and never block anything

This one is not an API gotcha. It is worse, because it is invisible from the code and only shows up in the funnel.

FamilyControls makes you climb two separate steps. First the user grants authorization, the system permission dialog with the ominous wording about restricting apps. Then, in a completely separate interaction, the user opens the FamilyActivityPicker and actually selects which apps to block. Engineers tend to treat the authorization grant as "done." Users treat the scary system dialog as the finish line and then drift away before picking anything.

The receipts: roughly 35 percent of installs granted Screen Time permission and then never selected a single app, category, or website to block. That cohort retained far worse than the users who finished, which makes sense, because an app blocker that blocks nothing is just a habit list with extra setup.

~35%of installs granted Screen Time permission but never selected any apps to block, until we gated the picker.

The fix is embarrassingly small and it is the highest-leverage thing in this post. Gate the picker. The Done button stays disabled until the selection is non-empty, and tapping the disabled button surfaces an inline message ("Select at least one app to block") rather than letting the user escape an empty configuration. Pairing that with a hint that you tap a category to drill into individual apps, because the picker's two-level structure is itself a place users get stuck, turned the dead 35 percent into the productive cohort.

The general lesson for any FamilyControls app: authorization is not activation. The permission grant is the easy half. The selection is where the funnel actually leaks, and it leaks silently because nothing errors. You have to instrument the gap between "granted" and "blocked something" or you will never see it.

Habit Doom
Lock distracting apps until your habits are done. No sign-in required.
★★★★★ 5.0 on the App Store
AppleDownload Free

Re-blocking shares a hot path with everything else

The intro guide makes the case for a synchronous re-block the instant your app comes to the foreground, before the database or UI initializes, so an expired unlock session gets re-shielded before the user can exploit the gap. That advice stands. What the guide does not warn you about is how crowded that foreground moment is.

App-became-active is where everything piles up. The re-block check runs there. So does state reconciliation. So, in our case, did a StoreKit entitlement sync that fetched, mutated, and saved a SwiftData object. The crash we shipped in v1.48 was not in the blocking code at all, but it lived on the same hot path and it is the kind of thing that takes a feature like app blocking down with it.

The bug: the entitlement sync ran twice per foreground. Once because updatePurchasedProducts() posts a notification that an observer reacts to by syncing, and once because the foreground handler also called the sync explicitly after its await. Two overlapping fetch-mutate-save cycles on the same ModelContext made SQLite's statement preparation fail, surfacing as an Objective-C exception that bypassed Swift's try/catch and killed the app on foreground. The fix was deleting the redundant explicit call and letting the notification path own the sync.

The reason this belongs in a FamilyControls post: the foreground moment is sacred for app blockers, because it is your last synchronous chance to re-apply the lock before the user acts. Anything else that crashes on that path takes your re-block down with it. So the rule we now hold: keep the foreground path lean and audit it for double-fires. Any time an await is followed by an explicit "now do the same thing" call, check whether the awaited work already published a notification someone else is handling. Double-writes to SwiftData on the foreground path are a genuine production hazard, and on this path the blast radius is the feature that defines the app.

A sibling of the same class of bug bit us in onboarding. A startup path sets an "unlocked" flag when there are no habits yet (nothing to earn, so nothing to block). If that flag is not explicitly reset when the user creates their first habit, it leaks forward and the quota arithmetic short-circuits, leaving the user staring at "Time Earned 00:00" after a real check-in. The fix was a one-line reset at the onboarding completion point. The pattern worth internalizing: lifecycle transitions that flip a persistent flag must reset it on the next meaningful state change. Do not assume the next code path will overwrite it, because in a multi-process app the next path may be in a different process entirely.

App Review and the manifest traps

The FamilyControls entitlement (com.apple.developer.family-controls) is the part everyone worries about, and it is genuinely a gate: it is a managed capability you request, not a checkbox you tick. Be ready to explain, clearly, that your app is a user-facing tool the user opts into for their own device, not something operating on anyone else's behalf. Beyond that, the traps that actually cost us time were not the entitlement, they were the privacy paperwork around it.

App Privacy is not the same as permission prompts. The App Privacy section in App Store Connect declares collected data, meaning data you transmit off the device. The Info.plist usage strings (NSCameraUsageDescription and friends) drive the runtime permission dialogs. They are different systems answering different questions, and conflating them is the most common way to either over-declare or under-declare. When we added a camera, the photo library, and on-device ML, we added zero new App Privacy entries, because none of it crosses the device boundary. On-device processing is not collection. The usage strings handle the prompts; App Privacy stays empty because there is nothing to declare.

Required-reason APIs are scoped to attribute keys, not to the call. This one is genuinely fiddly. Reading a file's .size through FileManager.attributesOfItem is not a required-reason API. Reading .creationDate or .modificationDate from the same call is. The privacy manifest categories key off the specific attribute you read, not the parent API, so it is entirely possible to audit your code by API name and still mis-file the manifest. UserDefaults (which every FamilyControls app leans on hard for cross-process state) has its own reason code, CA92.1. Audit by attribute, not by method name, or the manifest will be subtly wrong in a way that only review or a later policy sweep surfaces.

The meta-point on review: the FamilyControls capability earns you scrutiny, but the rejections and the slow back-and-forth tend to come from the privacy declarations being inconsistent with what the binary actually does, not from the blocking itself. Get App Privacy, the usage strings, and the privacy manifest telling the same true story, and the review process is far smoother than the entitlement's reputation suggests.

The shape of the thing

None of this is in the reference docs because none of it is about the API surface. It is about what happens when the API meets real hardware, a real funnel, and a real review queue.

The extensions are smaller than you think, so push all the work and all the cleverness into the main app and keep the extensions dumb. The tokens know less than you think, so design as if you will never learn which app the user blocked, because you will not. The funnel leaks between "granted" and "blocked," so gate the selection and instrument the gap. The foreground path is more crowded than you think, so keep it lean and audit it for double-fires, because that is where your lock lives or dies. And the privacy paperwork, not the blocking code, is where review actually friction-checks you, so make App Privacy, the Info.plist strings, and the manifest agree.

The blocking itself, once it is standing, is solid. The shield holds through force-quit, restart, and uninstall by design, and that tamper-resistance is the whole reason the API is worth the trouble. For how that lock plugs into the actual product loop, see how Habit Doom works. For the broader catalogue of things that nearly sank the build, 21 bugs that nearly killed my app is the long version.

Habit Doom is the app all of this runs in. It locks your distracting apps until your daily habits are done, and the lock is built to survive every shortcut someone might reach for. Free on the App Store.

Frequently Asked Questions

Apple does not publish a number, but the DeviceActivityMonitor and Shield extensions run under a very tight memory budget, far smaller than the main app. In Habit Doom's experience anything heavyweight (large models, big image decodes, deep object graphs) cannot live in those extensions. They have to do the minimum: read shared state from App Group UserDefaults, apply or clear a ManagedSettings shield, and exit. Any real work belongs in the main app.
No. ApplicationToken, ActivityCategoryToken, and WebDomainToken are deliberately opaque. They carry no display name, no bundle identifier, and no human-readable metadata. You can only render them by passing the token into a SwiftUI Label, which the system resolves to the real icon and name at display time. The opacity is the privacy design: your app never learns which specific apps the user blocked. It also means tokens cannot be meaningfully logged, persisted off-device, or shared between teams.
App Privacy in App Store Connect declares collected data, meaning data transmitted off the device. On-device-only processing is not collection. Habit Doom added camera, photo library, and an on-device ML model without any new App Privacy entries because nothing crosses the device boundary. The Info.plist usage strings (NSCameraUsageDescription and similar) are a separate system that drives the permission prompts. The two are easy to conflate and they answer different questions.
It is a funnel hole, not an API bug. Granting FamilyControls authorization and actually selecting apps in the FamilyActivityPicker are two separate steps, and a large share of users complete the first and abandon the second. In Habit Doom's analytics, around 35 percent of installs granted Screen Time permission but never selected any apps to block, and that cohort retained far worse. The fix was a UI gate: the picker's Done button stays disabled until at least one app, category, or website is selected.
No. The shield is applied by the system through ManagedSettingsStore and enforced at the operating system level, so force-quitting the app, restarting the phone, or deleting the app does not lift it. The only way to remove a shield is the owning app's own code clearing it. Habit Doom's lock is designed to be tamper-resistant for exactly this reason.
Habit Doom is free to download and use. Habit tracking, app blocking, custom alarms, and streaks work without paying. Premium features are available at $2.99/month, $19.99/year (with a 3-day free trial), or $49.99 lifetime. 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