Stateless Actors

Hacker News Top News

Summary

A technical exploration of stateless actors in Swift, discussing their uses, trade-offs, and comparisons with structs using concurrent functions, including examples like network clients and background actors.

No content available
Original Article
View Cached Full Text

Cached at: 05/30/26, 07:28 PM

# Stateless Actors Source: [https://www.massicotte.org/stateless-actors/](https://www.massicotte.org/stateless-actors/) May 29, 2026 Recently, I was asked an interesting question\. If the purpose of an actor is to protect mutable state, is a stateless actor pointless? At first, I thought it was an easy answer\. Actors exist to define a little, protective bubble around state\. They "isolate" data away from any unsafe accesses\. An actor that has nothing to isolate seems like a strange thing\. Can such an arrangement serve a purpose? **Note** I wrote another thing on[actors](https://www.massicotte.org/actors)that might be interesting\. ## Easy non\-MainActor types A thing I run into from time to time is a "NetworkClient"\-style type\. It contains methods that deal with some network API\. It isn't uncommon for these kinds of types to be actors\. ``` actor NetworkClient { func loadCart() async throws -> [Product] { let (data, _) = try await URLSession.shared.data(for: cartRequest) return try JSONDecoder().decode([Product].self, from: data) } } ``` This particular`NetworkClient`is an actor that has no state\. But it being an actor gives it two advantages\. First, actor types are`Sendable`\. That means we can pass this type around easily without having to think very much\. And second, this`loadCart`method will*never*run on the main thread\. Default actor types execute their synchronous work on a shared thread pool\. The JSON decoding here could be expensive\. Ensuring that work does not ever happen on the main thread is nice\. I'm reaching a little, but a believable third reason is this type might just not have state**yet**\. We might end up with caches or authentication, and all that will need a place to live\. \(It has been pointed out that predicting ahead of time where state could live can itself be problematic\. I tend to agree so be extra careful here\.\) I think this is fine**provided**you are doing all this intentionally\. As long as you understand the trade\-offs, go for it\. But there really are trade\-offs here\. Most notably, actor types can be difficult/impossible to use with protocols\. And, they also require**both**their method inputs and outputs to be safe to transfer into/out of the actor\. They push you towards needing**more**`Sendable`types\. Now, contrast with this: ``` struct NetworkClient: Sendable { @concurrent func loadCart() async throws -> [Product] { let (data, _) = try await URLSession.shared.data(for: cartRequest) return try JSONDecoder().decode([Product].self, from: data) } } ``` This type has some advantages\. The first is it can be easier to use with protocols because you won't have to wrestle with isolation mismatches\. The second problem is an artificial limitation\. Actors run synchronous blocks of code serially\. This means no matter how many tasks you throw at this`NetworkClient`actor, it will only be able to decode JSON responses one at a time\. With a`@concurrent`function, we no longer have that limitation\. I'm definitely not saying that a struct is better here\. But there are serious trade\-offs and they are worth thinking about\. **Note** Check out this[post](https://www.massicotte.org/synchronous-work)for more information on how to manage expensive work\. ## The background actor Here's an interesting type\! ``` @globalActor actor BackgroundActor { static let shared = BackgroundActor() } ``` And then, you can use it in all places where a global actor annotation works\. ``` Task { @BackgroundActor in // definitely not @MainActor anymore } ``` I get it\. We're used to seeing the one global`@MainActor`\. This gives us a familiar way to define non\-main work\. But such a type has two serious drawbacks\. Just like with our`NetworkClient`actor above, this`BackgroundActor`executes synchronous work serially\. It cannot run more than one background task at time\. That's not an ideal quality for a tool like this\. Another problem is that global actors integrate very tightly with the type system\. When you add one, you are forcing the compiler to guarantee the work is executed on that actor\. This can have a viral effect, something many people notice with`MainActor`\. And the reverse can also be true\. If you later change your mind,**removing**a global actor can also be painful\. I'm sympathetic to the motivations here\. But I really think you'll be best served by taking some time to learn about the language's existing constructs for controlling isolation\. I'm not sure that's really optional anyways, so it feels like a good investment\. ## Custom executor actors I somehow forgot about this one, but thankfully[Gwendal](https://hachyderm.io/@groue)did not let me get away with it\! It's not something you'll need everyday, but actors that exist purely to adapt Swift's concurrency system with another, preexisting system are very important\. This is one of the primary use cases for[custom executors](https://developer.apple.com/documentation/swift/serialexecutor)\. This is a powerful tool and is surprisingly easy to use with a dispatch queue\. I've seen this used to better integrate with[AVFoundation](https://developer.apple.com/documentation/avfoundation)\. But the approach can potentially work with any other queue\-based system\. If you'll allow me to lift an example from the[migration guide](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/incrementaladoption#Integrating-DispatchSerialQueue-with-Actors): ``` actor LandingSite { private let queue = DispatchSerialQueue(label: "something") nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() } func acceptTransport(_ transport: PersonalTransportation) { // this function will be running on queue } } ``` Another example of this, and one that I'm pretty embarrassed I didn't think of, is the`MainActor`\! This actor doesn't have any direct**properties**, but it most certainly does manage state \- the entirely of the UI\. So it kind of straddles the line here, but it's still very interesting to consider\. ## The file system Ok, this is an interesting one\. The file system is**absolutely**a form of state\. But it is state that is outside of our program and completely invisible to the compiler\. This is a case where I think a "stateless" actor can make sense\. The state does not have to literally mean instance properties\. Say you have some kind of on\-disk cache, used by lots of different parts of your system\. Concurrent accesses could corrupt the files/directories involved\. The serialization an actor provides gives you a way to prevent that\. It isn't ideal, because the compiler cannot check your work\. And getting it right does require manually encapsulating everything, but you probably want to do that anyways\. One concern that can come up here is blocking operations\. When you read/write to the disk, you're doing it synchronously\. That means this actor is tying up one of the concurrency runtime's threads\. Those are a**finite**resource, and on the order of the number of CPU cores per priority level\. This is quite different from GCD, which will happily create a large \(but still ultimately finite\) number of threads if none are available\. My opinion is that you usually do not need to concern yourself with this\. Sure, a thread is occupied\. But what specifically it is occupied**doing**is typically not an important detail\. As long as the work satisfies the runtime's requirement of forward progress, you should be fine\. However if you are worried \(or know for a fact\) you will not be fine, shifting your blocking work off the concurrency pool's threads makes sense and is usually quite easy to do\. GCD is still here and you should not be afraid to use it\. \(I forgot that[Jaim](https://bsky.app/profile/jaimzuber.com)also[wrote](https://jaimzuber.com/swift-concurrency/empty-global-actors)about this, and went into quite a bit more detail\. Worth checking out\.\) ## The first rule of actors Actors have a tendency to be over\-used\. They are a very useful tool, and I prefer them to locks or queues\. But like any synchronization primitive, you should be able to clearly articulate why it is necessary\. This is the first rule of actors\. I think that**in general**, yes\. An actor with no state is a strange thing\. It could represent a misunderstanding\. It might be making a design more complex\. But I think they definitely can also make sense\. Did you know that I do consulting for concurrency and Swift 6 migrations? If you think I could help,[get in touch](https://www.massicotte.org/consulting)\.

Similar Articles

@djfarrelly: https://x.com/djfarrelly/status/2052779234234380479

X AI KOLs Timeline

The article argues that AI agent development should rely on stable execution primitives rather than rigid frameworks, which frequently change with emerging orchestration patterns. It emphasizes durable steps, persistent state, parallel coordination, event-driven flow, and observability to prevent costly rewrites as best practices evolve.

Understanding Singleflight in Go

Hacker News Top

The article explains the singleflight pattern in Go, which eliminates redundant concurrent calls to expensive operations by ensuring only one call is in flight at a time, sharing results among all callers.