Workstation4 / Blog / Swift
Swift SwiftUI Performance AdMob

A banner ad that quietly froze the launch for 30 seconds.

After I fixed a launch crash in WordErase, the app still made you wait about thirty seconds to see today's board. Nothing was broken anymore; it was just frozen. The fix had nothing to do with the puzzle, the network, or the dictionary. It was a Google AdMob banner, quietly booting an entire web rendering stack on the most expensive line of the render path, and the rest of the screen was politely waiting in line behind it.

A clean launch that still felt slow

The crash was gone, so I hoped the puzzle would snap into place. Instead the screen sat blank for the better part of half a minute before the first tile appeared. In some ways a frozen launch is trickier than a crash. A crash tells you something is wrong; a thirty-second stall just feels like nothing is happening. Someone opening WordErase for the first time might reasonably assume it was stuck and never give it a second try, and the App Store does not show you those people.

So rather than guess, I went looking for what the app was actually doing during those thirty seconds.

Letting the console name the culprit

The console told the story pretty plainly. Three system processes were spinning up at launch, and each one was reporting startup times of ten or more seconds:

  • the GPU process
  • the WebContent process
  • the Networking process

That trio is the signature of a WKWebView coming to life. AdMob banners are not native views; under the hood the SDK renders the creative in WebKit, which means a banner drags in the same multi-process browser engine Safari uses. None of those processes have anything to do with drawing a grid of letter tiles. The only web content anywhere near launch was the banner, and the timing lined up exactly with the freeze.

A blank screen is a question. The console usually has the answer, if you stop guessing and go read it.

Two costs hiding on the critical path

There were actually two problems stacked on top of each other, and untangling them was half the work.

The first was ordering. The original launch downloaded every missing puzzle before showing anything. If you had a hundred days of backlog, it fetched all hundred before drawing today. I had already split that apart: load today from the on-disk cache instantly, download only today if it is missing, then queue the rest, past dates first and at most seven days into the future, on a background utility queue. That alone took launch from forty-plus seconds down to instant on a warm cache.

But a second cost remained, and this is the one the console exposed: the AdMob SDK. In the original code, MobileAds.shared.start() ran synchronously during app initialization, before the UI ever rendered. Starting the SDK is what brings the WebKit processes up, so the whole expensive boot was sitting squarely in front of the first frame. I had, without meaning to, made an advertisement a hard dependency of showing the game. That is exactly backwards. Players launch WordErase to see the puzzle; they are not waiting for an ad, so an ad should never be the thing that blocks the puzzle.

Reserve the space, gate the work behind consent

The fix came in a few parts, and none of them made the banner faster. The banner stayed exactly as slow as it always was. I just stopped letting it hold up everything else.

I pulled SDK startup out of init entirely and gave it to a small @MainActor singleton, AdMobManager. It is an ObservableObject with two published flags: isInitialized and canShowAds. Initialization happens inside a Task, not on the launch path, and only after GDPR consent has actually been obtained. There is a happy accident in that requirement: because EU consent has to be resolved before AdMob is even allowed to start, the SDK boot was already destined to run after the board, not before it.

@MainActor
final class AdMobManager: ObservableObject {
    @Published private(set) var isInitialized = false
    @Published private(set) var canShowAds = false

    func initializeIfNeeded() {
        guard !isInitialized else { return }
        Task { @MainActor in
            guard ConsentInformation.shared.canRequestAds else { return }
            await withCheckedContinuation { cont in
                MobileAds.shared.start { _ in cont.resume() }   // boots WebKit, off the launch path
            }
            isInitialized = true
            updateCanShowAds()   // canShowAds = isInitialized && consent
        }
    }
}

The view side then becomes a state machine instead of a blocking call. The banner is a UIViewRepresentable wrapping AdMob's BannerView. Its makeUIView creates the view but refuses to call load until the manager says ads are allowed. When canShowAds flips, SwiftUI runs updateUIView and the ad finally requests:

func updateUIView(_ uiView: BannerView, context: Context) {
    if adMobManager.canShowAds && !context.coordinator.hasLoadedAd {
        context.coordinator.loadAdIfNeeded(uiView)   // idempotent; guards against duplicate loads
    }
}

The whole consent-then-initialize handshake lives in the puzzle view's .task, which SwiftUI runs after the view appears. So the board renders from cached data first, the player can start swiping, and only then does the consent flow resolve and the web stack quietly come up in the background. When its processes finish, the ad fades in. No blocking, no frozen launch.

A 10-point gap that made the board jump

The last piece was subtle and easy to miss. The banner slot reserved a fixed-height area so the layout was settled from the first frame, but the placeholder was 50 points tall while the real ad came in at 60. That ten-point difference meant the entire board lurched upward the instant the ad arrived, often right as someone was mid-swipe. A layout shift during a touch gesture is genuinely disorienting in a game, because your finger is tracking a tile that just moved.

The banner uses currentOrientationAnchoredAdaptiveBanner(width:) to size itself to the device, and that adaptive height is what I should have reserved all along. Matching the placeholder to the real adaptive height meant the ad dropped into space that was already accounted for, and the board never moved.

  • Reserve the exact height the content will occupy, not a guess.
  • If the size is adaptive, ask the SDK for the adaptive size and reserve that.
  • A stable layout costs nothing to render, so there is no reason not to settle it on frame one.

A couple of small traps worth naming

Two details bit me while wiring this up, and both are the kind of thing you only learn by hitting them.

The first is duplicate ad requests. Because updateUIView can run many times, naively calling load there fires a request on every state change, which AdMob does not love. The coordinator carries a hasLoadedAd flag and the load is guarded, so it is idempotent; on a load failure I reset the flag so a retry is still possible.

The second is the root view controller lookup. A banner needs a rootViewController to present full-screen tap-throughs, and walking connectedScenes to find the key window is not free if you do it on every render. I cache it once in the coordinator at view-creation time. None of this changes how fast the SDK boots, but it keeps the per-frame cost of having a banner on screen near zero.

Ordering work, not just speeding it up

This was the second time on this project that the win came from reordering work rather than speeding any single piece of it up. The puzzle backlog was the same shape of problem: load today first, push everything else to the background. The banner freeze was that lesson again in a different costume, and the resolution was identical. Move the expensive thing off the path the user is actually waiting on.

The takeaways I am carrying forward:

  • A blank screen at launch is a profiling problem, not a mystery. Read the console before you theorize; those GPU and WebContent process timings pointed straight at the cause.
  • An AdMob banner is a WKWebView in disguise. Starting the SDK boots a whole multi-process browser engine, so treat it as expensive and keep it off the launch path.
  • Model expensive work as state, not as a blocking call. A published canShowAds flag let the UI render first and pull the ad in later, on its own time.
  • Reserve the exact layout space the content needs. A ten-point mismatch was enough to make the board jump under someone's finger.
  • Defer anything the user is not actually waiting for until after the thing they came for is on screen.

The board now appears right away, the ad arrives when it is ready, and a thirty-second freeze turned out to be a question of when, not how fast.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →