Your tvOS Top Shelf tile is cached, and reinstalling won't clear it
The Top Shelf tile for AmbientCast, my printer-monitoring app, started rendering a dark placeholder instead of its artwork. Everything else about the tile was fine: the title line was correct and current, and the progress indicator tracked the real job percentage. Only the image was missing. I spent an evening on it, wrote two fixes that were both wrong, and the thing that eventually worked was rebooting the Apple TV.
ELI5: what's the Top Shelf?
On an Apple TV, highlighting an app on the top row of the home screen makes a large banner appear above it. That banner is the Top Shelf, and it's the app's chance to show something live — what's playing, what's new, what's in progress — before you've opened it.
The app doesn't draw it. It hands the system a description of what should appear, and the system renders it. That gap between what you hand over and what gets drawn is the whole subject of this post.
That is an unsatisfying ending, so let me give you the useful version up front: the system caches the rendered Top Shelf tile, dedupes incoming content against that cache, and the cache survives uninstalling and reinstalling your app. If you have shipped even one build that handed the Top Shelf a bad item, you can spend hours looking at correct code that produces a wrong screen. Nothing about the symptom suggests a cache, which is exactly why it costs so much time.
Update, September 2026 — on the tvOS 27 release build, a reinstall is enough. I re-ran this on the shipping OS and could not reproduce the expensive half: a cached placeholder was replaced by correct artwork on the next install, with no reboot. The reboot below is what worked at the time and on the build named below; try reinstalling first. The re-check is at the end of this post.
What I ruled out first, and why it wasn't enough
This was a TVTopShelfContentProvider returning a single
TVTopShelfSectionedItem with imageShape = .hdtv,
built against a tvOS 17 deployment target and observed on a physical Apple
TV 4K (3rd generation) running the tvOS 27.0 public beta, build 24J5325d.
Before changing anything I checked the obvious candidates, all on device:
-
Was the extension crashing? No — no crash reports for it
at all. You can pull every crash log off a tethered Apple TV with
devicectl device copy from --domain-type systemCrashLogs, which is a fast way to answer "is this thing dying or just rendering wrong?" - Was the data stale? No. The extension reads a snapshot the app writes into a shared App Group. I pulled that container off the device and decoded it, and it was current.
-
Were the images actually in the built
.appex? Yes, both of them, at 400×225 and 800×450. - Was the extension even running? Yes, and this is the clue I misread. The tile's title and progress bar matched the live state, so the extension was running, reading its snapshot, building its item, and returning it. Only the image was absent.
That last point felt like it narrowed things down enormously. If the item renders, the extension works; if the image alone is missing, the bug must be in the image. That reasoning is what sent me off in the wrong direction, because it assumes the tile on screen was rendered from the item I was currently returning. It wasn't.
ELI5: what's an app extension, and an App Group?
An app extension is a small separate program that ships inside your app but runs on its own — here, the bit that supplies the Top Shelf banner. The system starts it when it wants content, and it's a genuinely different process from the app, with its own restrictions.
Because they're separate, they can't simply read each other's files. An App Group is a shared folder both are permitted to open, which is how the app leaves a snapshot of the current state where the extension can find it. Think of it less as "the same program" and more as two coworkers sharing one drawer.
Getting logs out of a Top Shelf extension
Before anything else got better, this did, and it's worth its own section because it applies to any tvOS extension.
print() from a Top Shelf extension reaches nobody. It goes to
stdout, and nothing is attached to that process on a real Apple TV — the
extension runs on the system's schedule when the Home screen wants content,
which is precisely when you are not sitting in Xcode with a debugger
attached. log stream is no help either: it reads the Mac it
runs on, and the stream subcommand can't be aimed at a
connected device.
One correction to that, since this was published. I originally wrote that
there was no command-line path to a device's logs at all, which was too
strong. There is one, it just isn't a live one:
log collect --device-udid <udid> is in the
log man page and pulls a paired device's unified log into a
.logarchive that log show will read. I used
Console.app and never tried it against the Apple TV, and accounts of it
working on paired devices other than iPhones are mixed — so try it before
you go tethering, but I can't tell you it works there.
What works is os.Logger at .notice, read in
Console.app with the Apple TV tethered. Not .debug — debug-level
messages aren't persisted by default, so they'll be missing from exactly the
capture you bothered to collect. Use privacy: .public on any
interpolation you actually want to read, or you'll get
<private> where the answer should be.
let log = Logger(subsystem: "com.example.app", category: "TopShelf")
let path = url.path
log.notice("""
image=\(path, privacy: .public) \
exists=\(FileManager.default.fileExists(atPath: path), privacy: .public) \
readable=\(FileManager.default.isReadableFile(atPath: path), privacy: .public)
""")
Logging whether the file exists and is readable from this process was the specific thing I needed. If the path is readable from the extension and the tile still shows a placeholder, then either the rendering process can't reach it from its own sandbox or it's rejecting the image after reading it — two problems with completely different fixes, and no way at all to tell them apart from a screenshot.
Two things I believed about App Groups on tvOS that were wrong
My first fix was to copy the images out of the extension's bundle and into
the App Group container, on the theory that a path inside an
.appex isn't reachable from the system process that draws the
shelf. That fix didn't work, so I concluded that App Groups on tvOS share
UserDefaults and nothing else — that
containerURL(forSecurityApplicationGroupIdentifier:) just
returns nil there. I wrote that in a comment. It is false, and the device log
says so plainly:
container_create_or_lookup_app_group_path_by_app_group_identifier: success
The container resolves fine. What's refused is writing to it from an extension:
Sandbox: MyTopShelfExtension deny(1) file-write-create
/private/var/.../Shared/AppGroup/<uuid>/Library/TopShelfImage@1x.png
That surfaces in Swift as NSCocoaErrorDomain 513. So the real
rule is: an app extension can read the shared container but cannot
create files in it, while the containing app can do both. If you
need something dynamic on the shelf, the app writes it and the extension
only reads it.
ELI5: what's a sandbox, and what is "deny(1)"?
Every app on Apple's platforms runs inside a sandbox: a set of rules about which files it may touch, enforced by the operating system rather than by the app agreeing to behave. Reach outside the lines and the system simply refuses.
deny(1) file-write-create is the system saying so in its own
log — this process asked to create a file at that path and was denied.
It's useful precisely because it comes from outside your code: it proves
the request was made and rejected, rather than never happening.
Worse, my failed copy didn't fall back. It returned nil for both image URLs
when the copy failed, so several builds went to the device handing the system
image1x=nil image2x=nil — guaranteeing the blank tile the change
was supposed to fix. Remember that; it's the whole ending.
The log line that explains the rest
With the images back to plain bundle URLs and the log showing good paths going out, the tile still drew a placeholder. Then this turned up:
Skipping content update for [com.example.app] because it is unchanged
The Home screen process dedupes fetched content against its cached model. My item identifier was a constant string, and with the printer parked on the same status, a corrected item was indistinguishable from the cached one. A fix to the image alone could never reach the screen, because from the system's point of view nothing had changed.
That's a genuine trap independent of everything else here, and the fix is cheap — derive the identifier from the content you're rendering:
// Before: a constant. A corrected item looks identical to the cached one.
let item = TVTopShelfSectionedItem(identifier: "current-item")
// After: identity varies with what's actually in the item.
let identity = [title, state, imageURL?.path ?? "no-image"].joined(separator: "|")
let item = TVTopShelfSectionedItem(identifier: "current-item-\(identity)")
Including the image path is deliberate: a bundle path embeds a UUID that changes on every install, so a reinstall always produces a fresh identifier.
One trap on the way there, which I walked straight into: don't run that
string through hashValue. Swift seeds Hashable
with a value chosen randomly per process, and the documentation is explicit
that hash values "are not guaranteed to be equal across different
executions of your program." The Top Shelf extension gets launched fresh
every time the system wants content, so
identity.hashValue yields a different number on every run
whether or not anything in the item changed. It does defeat the dedupe —
but for a reason that has nothing to do with the content, which makes the
identity string you just assembled pure decoration. Worse, it's the kind of
accident that stops being true the moment someone swaps in a stable hash
and quietly restores the caching you were trying to escape. Use the string
itself; identifiers are not required to be short.
And it still didn't fix it. Good paths, fresh identifier, placeholder tile.
What actually cleared it
Rebooting the Apple TV. Instantly, with no code change.
The cached rendering was one built while my own commits were returning nil image URLs. Once the Home screen had cached a model with no images, nothing displaced it: not a corrected build, not a changed item identifier, not uninstalling and reinstalling the app. It kept faithfully drawing a tile from builds ago for hours, while every diagnostic I could run said the current code was correct — which it was.
One honest caveat, because it's the part I can't close: my nil-URL commits explain why it stayed broken all evening, but the tile was already showing a placeholder before I wrote them, which is why I opened the bug in the first place. I never isolated what poisoned the cache originally. The most I can say is that it was a day of repeated installs, and that a reboot cleared whatever it was.
What I'd tell you to check first
If a Top Shelf change doesn't seem to take effect, reboot the device before you doubt the code. It costs a minute. Doubting the code cost me an evening and produced two commits that made things worse.
The general shape is worth carrying past tvOS. I had a cache I didn't know existed, sitting between correct code and a wrong screen, and every instrument I had was pointed at the code. All my logging measured what the extension sent. Nothing measured what the system drew, and the two had quietly stopped being the same thing. When a fix that should work changes nothing at all — not the symptom, not even its shape — stop refining the fix and start asking whether your output is reaching the renderer.
And when you're debugging blind, don't let a broken build ship a null. A fallback that returns yesterday's correct value is annoying. A null gets cached somewhere you can't see, and then you're debugging your own debugging.
Update: re-checked on the tvOS 27 release build
The observation above was made on the tvOS 27.0 public beta, build 24J5325d. tvOS 27 shipped on 14 September 2026, and I re-ran the experiment on the same physical Apple TV 4K (3rd generation), now on tvOS 27.0 build 24J361. The behaviour that cost the evening does not reproduce.
To test it properly I had to defeat my own fix. The app now derives the item's identifier from its contents, including the image path — and that path contains the app's bundle UUID, which changes on every install, so every reinstall produces a fresh identifier and would hide the thing I was trying to measure. So the probe build used a constant identifier and a constant title, and set no progress value, leaving the image bytes as the only variable.
-
Prime the cache with a bad item. A build handing the
system
nilfor both image URLs — exactly what the original bad build did — rendered the same dark placeholder. Symptom reproduced. - Then fix it, and don't reboot. A build with valid images and the same constant identifier, installed over the top: the artwork was back on the Home screen about two minutes later. No reboot.
- Control. An earlier pair of builds swapped the real artwork for a solid red image under that same constant identifier. That updated immediately too, so this is not a special case of recovering from a null.
What this does and does not show. A reinstall necessarily moves the extension's bundle, so the image URLs differ between two builds even when the identifier does not. That was equally true during the original investigation, where the placeholder survived reinstall after reinstall anyway — so the conditions match the original report as closely as a rebuild allows. What I can say is that a reinstall now evicts the stale tile, which is the part that was costing hours. I did not test whether a tile self-corrects with no reinstall at all.
Two things from the original post survive unchanged. Don't let a broken build ship a null — that advice was never about the cache, it was about not handing a renderer something you can't see the consequences of. And deriving the identifier from the content is a few lines that make the question moot on any OS version, which is worth keeping whatever the platform does this year.