Do not be misled by the name: A SerialExecutor is designed to solve a very different problem, namely when you do not want an actor to use the cooperative thread pool. But it sounds like you simply want a series of tasks to run sequentially (with no actor-reentrancy).
I might recommend AsyncChannel from the swift-async-algorithms package. You would have a for-await-in loop monitor the channel, and then you can send ticket identifiers to that channel:
actor TicketService {
private let channel = AsyncChannel<String>()
func monitorChannel() async {
for await ticketId in channel {
await ticketHandler(ticketId: ticketId)
}
}
func submitTicket(ticketId: String) async {
await channel.send(ticketId)
}
}
private extension TicketService {
func ticketHandler(ticketId: String) async {
try? await Task.sleep(for: .seconds(1))
}
}
And you would monitorChannel when the app starts, have submitTicket send ticket identifiers, and ticketHandler will process them one at a time. For example, in SwiftUI:
struct DetailView: View {
let service = TicketService()
@State var id = 0
var body: some View {
VStack {
Button("Submit") {
id += 1
Task {
await service.submitTicket(ticketId: "\(id)")
}
}
}
.padding()
.task {
await service.monitorChannel()
}
}
}
Yielding:

So, you can follow what is going on in this timeline, the Ⓢ signposts are where I clicked the button to submit a new ticket, but you can see the ticketHandler lane in this “Points of Interest” graph will process them one at a time.
In this run, represented by the above “Points of Interest” timeline:
- I clicked three times (faster than
ticketHandler could run them) … demonstrating that it handles these sequentially while the UI remains responsive;
- I waited until they finished, and clicked one more time … demonstrating that if there were no going, that this fourth ticket was handled immediately;
- I then dismissed the view (canceling the monitoring of the channel) … demonstrating that you can cancel this channel, if that is desired;
- I then re-entered the view in question and clicked 9 times in quick succession, but then canceled all of this before they finished … demonstrating cancelation of the channel in progress.
In the above code snippets, I omitted the Instruments’ “Points of Interest” instrumentation to avoid distracting you with code unrelated to your question at hand. But here is the complete MRE:
import os.log
import AsyncAlgorithms
let poi = OSSignposter(subsystem: "TicketService", category: .pointsOfInterest)
actor TicketService {
private let channel = AsyncChannel<String>()
func monitorChannel() async {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
for await ticketId in channel {
await ticketHandler(ticketId: ticketId)
}
}
func submitTicket(ticketId: String) async {
let state = poi.beginInterval(#function, id: poi.makeSignpostID(), "\(ticketId)")
defer { poi.endInterval(#function, state, "\(ticketId)") }
await channel.send(ticketId)
}
}
private extension TicketService {
func ticketHandler(ticketId: String) async {
let state = poi.beginInterval(#function, id: poi.makeSignpostID(), "\(ticketId)")
defer { poi.endInterval(#function, state, "\(ticketId)") }
try? await Task.sleep(for: .seconds(1))
}
}
And:
struct DetailView: View {
let service = TicketService()
@State var id = 0
var body: some View {
VStack {
Button("Submit") {
id += 1
poi.emitEvent("Click", "\(id)")
Task {
await service.submitTicket(ticketId: "\(id)")
}
}
}
.padding()
.task {
await service.monitorChannel()
}
}
}
I then profiled this with Instruments, selected the “Time Profiler” template, and then started the recording.