Global Actor Inference Doesn’t Block the Main Thread — Your Synchronous Work Does

There is a persistent piece of folklore in Swift concurrency discussions that goes something like this: “@MainActor inference can cause unexpected main-thread blocking.” It sounds plausible. Inference is invisible, invisible things are suspicious, and the main thread is where jank lives. But the claim, as usually stated, conflates two separate mechanisms.

Global actor inference is a rule about isolation: which declarations the compiler treats as belonging to a global actor. It is not a rule about execution cost. The Swift Evolution proposal that introduced global actors, SE-0316, describes inference as a way for unannotated declarations to pick up actor isolation from context. Nothing in that proposal says inferred isolation makes code slower, or that inference itself schedules work onto the main thread.

What can block the main thread is synchronous work that must run on the main actor — whether that isolation was written by hand or arrived through inference. That distinction matters, because it changes what you audit and what you fix. This article walks through the documented inference rules, explains why they widen the surface area of main-actor code, and lays out a practical review workflow. The inference rules are sourced; the workflow is engineering advice, and I will label it as such.

What SE-0316 actually says about inference

The proposal is explicit that inference is a fallback for declarations that are not already annotated:

“Declarations that are not explicitly annotated with either a global actor or nonisolated can infer global actor isolation from several different places.”

It then enumerates those places. The list is worth reading carefully, because each rule is a way for @MainActor to appear in your code without you typing it.

1. Subclasses inherit their superclass’s isolation

SE-0316 states that “subclasses infer actor isolation from their superclass.” If a base class is @MainActor, every subclass is too, unless something else intervenes. This is the rule most likely to surprise someone reading a diff: a new subclass file may contain no actor annotation at all and still be main-actor-isolated.

2. Overrides inherit from the declaration they override

“An overriding declaration infers actor isolation from the declaration it overrides.” Override a @MainActor method and your override is @MainActor, even if the override’s body has nothing to do with UI.

3. Protocol witnesses infer from the requirement — under conditions

The proposal says a witness “that is not inside an actor type infers actor isolation from a protocol requirement that it satisfies, so long as the protocol conformance is stated within the same type definition or extension as the witness.” The placement condition is the part people miss. Put the conformance and the witness in the same extension and the witness inherits the requirement’s isolation. Split them across extensions and the inference does not apply the same way.

4. Conforming to a global-actor-qualified protocol can isolate the whole type

“A non-actor type that conforms to a global-actor-qualified protocol within the same source file as its primary definition infers actor isolation from that protocol.” This is the rule with the widest blast radius. A type that conforms to a @MainActor protocol in its primary declaration becomes main-actor-isolated as a type, not just for the protocol’s requirements.

5. Property wrappers can isolate the containing type

“A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper.” In SwiftUI code this is the rule that quietly makes view types main-actor-isolated through property wrappers whose wrapped values are main-actor-bound.

These five rules are the documented mechanics. They are not a performance story. They are a visibility story: they determine how much of your code the compiler considers main-actor-isolated.

Isolation is not the same as execution cost

Once a type is @MainActor, SE-0316 says “all of the methods, properties, and subscripts will implicitly be isolated to that global actor,” and that members can opt out “using the nonisolated modifier.” The proposal’s own example is a view controller where UI state and update methods are implicitly main-actor-isolated while a helper is marked nonisolated.

Here is the crucial sentence for our purposes: a declaration isolated to a global actor “can only be synchronously accessed from another declaration on the same global actor, but can be asynchronously accessed from elsewhere.” That is a rule about who may call synchronously. It says nothing about how long the callee takes.

So when does the main thread actually get blocked? When a synchronous function that runs on the main actor does expensive work: parsing a large payload, decoding images, doing synchronous disk I/O, running a tight loop over a big collection, or waiting on a lock. The isolation determines where that work runs. The work itself determines how long it runs there. Inference can put more functions into the main-actor bucket, which increases the chance that expensive synchronous work ends up in that bucket — but the blocking is caused by the work, not by the inference rule.

That reframing is not a quibble. If you believe inference is the cause, you go hunting for annotations. If you understand that synchronous main-actor work is the cause, you go hunting for expensive synchronous functions that happen to be main-actor-isolated, however they got there.

A practical audit workflow

The following is my recommended procedure, not a claim from the sources. Treat it as a review checklist you can adapt to your codebase.

Step 1: Map the inferred isolation

Start by finding types that are main-actor-isolated without an explicit annotation. The five rules above give you search patterns:

  • Subclasses of known @MainActor base classes.
  • Overrides of @MainActor methods.
  • Types whose primary declaration conforms to a @MainActor protocol.
  • Types containing property wrappers whose wrapped value is main-actor-qualified.
  • Witnesses declared alongside their conformance to a protocol with @MainActor requirements.

You do not need to memorize the compiler’s full inference algorithm to do this. You need a list of the types in your app that are main-actor-isolated and a note of why each one is. That note is what makes the next step tractable.

Step 2: Inspect synchronous methods for heavy work

For each isolated type, look at its synchronous methods. The question is not “is this method @MainActor?” — it almost certainly is. The question is “does this method do anything that takes meaningful time?”

Red flags include:

  • Loops over collections whose size scales with user data.
  • JSON encoding or decoding of large payloads.
  • Image resizing, color conversion, or bitmap manipulation.
  • Synchronous file or network access.
  • Sorting, diffing, or formatting large data sets.
  • Calls into C or Objective-C APIs that are not documented as cheap.

None of these are automatically wrong on the main actor. A small decode on a cold path is fine. The point is to find the ones that are not fine and to know why they are where they are.

Step 3: Separate state mutation from computation

The most reliable pattern is to keep the main-actor-isolated method responsible for reading and writing main-actor state, and move the computation elsewhere. A sketch:

@MainActor
final class FeedViewModel {
    private(set) var items: [Item] = []

    func load(payload: Data) async throws {
        // Hop off the main actor for the expensive part.
        let decoded = try await Task.detached(priority: .userInitiated) {
            try JSONDecoder().decode([Item].self, from: payload)
        }.value

        // Back on the main actor: cheap state assignment.
        items = decoded
    }
}

This example is illustrative, not a benchmark. The important structural point is that the expensive decode does not run inside a synchronous main-actor method. The await is where the main actor is released to do other work.

Step 4: Use nonisolated deliberately, not reflexively

Marking a helper nonisolated removes it from the main actor, which is sometimes exactly right for a pure function that touches no isolated state. But it also removes the compiler’s protection around main-actor state: a nonisolated method cannot synchronously touch main-actor-isolated properties. That is a feature, not an obstacle — it forces you to pass data explicitly across the boundary.

So nonisolated is a design decision about where state lives, not a one-line performance patch. If you find yourself adding nonisolated to a method that reads five main-actor properties, stop and reconsider the decomposition instead.

Step 5: Measure rather than assume

Apple’s guidance on diagnosing performance issues early is the right frame here: use Instruments to attribute time to actual work rather than reasoning from annotations alone. A main-thread hang has a stack trace. That trace will tell you which function was running, and from there you can ask whether it was main-actor-isolated and why. The annotation is a clue about scheduling; the trace is evidence about cost.

The Swift 6 language-mode caveat

One source-specific point deserves careful handling. Hacking with Swift’s article on global actor inference states that “global actor inference was enabled from Swift 5.5 through to Swift 5.10” and that “it is disabled in Swift 6 language mode.” That is a claim about a specific toolchain and language mode, and it should be read that way. If you are migrating to Swift 6 language mode, verify the behavior against your actual compiler and target settings rather than assuming the rules above apply unchanged. The SE-0316 text describes the inference rules as proposed; the Hacking with Swift article describes their status across versions. Both are worth reading, and neither substitutes for checking your build.

This caveat also reinforces the main argument. If inference is disabled in your language mode, then any main-thread blocking you observe is definitely not caused by inference — it is caused by explicit isolation plus expensive synchronous work. The diagnostic method is the same either way.

What to tell reviewers

If you are reviewing a pull request and see a new type that is main-actor-isolated without an explicit annotation, the useful comment is not “this will block the main thread.” The useful comment is a question: which synchronous methods on this type do non-trivial work, and should any of them be moved off the main actor?

A short checklist for that review:

  • Identify why the type is isolated — superclass, override, protocol conformance, or property wrapper.
  • List the synchronous methods and flag any that scale with data size or touch I/O.
  • Decide whether the expensive part belongs on the main actor at all, or whether it should be computed elsewhere and returned.
  • If you use nonisolated, confirm that the method no longer needs isolated state and that data is passed explicitly.
  • Confirm with a trace or a targeted measurement before declaring a performance problem.

Inference is a convenience that makes main-actor code easier to write correctly. It is not a performance mechanism, and treating it as one sends you looking in the wrong place. The main thread blocks when synchronous work runs on it. Find that work, move what can move, and leave the isolation rules to do their job.