FamilyControls in Production: The Edges
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.
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.
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.
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
Keep Reading
Try Habit Doom
Lock your distracting apps. Complete your habits. Earn your screen time. It takes 30 seconds to set up.