Blog › ICP guides
Io developer on retainer: prototype-based OOP, actor concurrency, coroutines, message introspection, and Io platform engineering on monthly retainer
October 26, 2026 · ~18 min read
An Io application processing batched counter increments was dropping work for 5 batches per day. The system used three actors implemented as Io Coroutines — each spawned with Coroutine withBlock(block) and started with coroutine resume — to process incoming messages concurrently and increment a shared counter. All three coroutines were started in sequence, but only the first actor processed its increments. The second and third actors received zero increments for every batch run. The Io developer on retainer diagnosed the root cause: Io’s scheduler is cooperative, not preemptive. A Coroutine that never calls yield holds the scheduler thread indefinitely. The first actor’s computation loop contained no yield calls; it ran to completion before the scheduler had an opportunity to switch to the second or third actor. The fix inserted yield at the midpoint of each actor’s inner computation loop, giving the scheduler the interleaving points it needed to distribute work across all three coroutines. Missed increments: 5 batches per day → 0.
The work log entry read “fixed actor scheduling bug for counter system, 14h.” It names the symptom and the duration. It cannot explain to a client why the fix required understanding Io’s cooperative multitasking model rather than calling a preemption API, why the absence of yield is not an error in Io (it is valid for a coroutine to run to completion without yielding, which is correct for short tasks but wrong for long-running actors that must share the scheduler), why inserting yield at the wrong point in the computation loop (e.g., after the loop rather than inside it) would still allow starvation of one iteration, or what the difference is between @ (asynchronous send, returns a Future) and @@ (fire-and-forget send, returns immediately with no result handle) when designing Io actor pipelines. The 14 hours of coroutine scheduling trace (running the system with logging to identify which coroutines executed and in what order), yield point analysis (identifying the computation midpoints inside each actor loop where scheduler interleaving was safe), actor body restructuring (inserting yield and verifying that all three actors processed their full batch), and regression testing (verifying counter totals across 20 batch runs with all three actors active) are not visible in the diff beyond three added yield lines.
Io’s prototype-based object model: clone hierarchies, slot discipline, and the := vs = distinction
Io is a prototype-based language in the tradition of Self. There are no classes: every object is a prototype, and new objects are created by cloning existing ones. The fundamental operation is clone: Counter := Object clone creates a new object Counter that delegates all unknown messages to Object. Counter count := 0 adds a count slot to Counter with initial value 0. A derived prototype: RateCounter := Counter clone creates RateCounter delegating to Counter. If RateCounter does not define its own count slot, then reading RateCounter count walks up the delegation chain and reads Counter count. Writing RateCounter count = RateCounter count + 1 uses = (assignment to an existing slot): Io looks up the count slot, finds it on Counter (the ancestor), and writes to that slot — mutating the ancestor prototype’s slot, not a local slot on RateCounter. This is the shared-slot mutation bug: all clones of Counter share a single count slot on the ancestor, so incrementing one increments all.
The correct pattern uses := (local slot set) in an init method. The init method is called by Io when a new clone is created: Counter init := method(count := 0). When Counter clone is called, Io creates a new object and then calls init on it. Inside init, count := 0 uses :=, which creates a new slot on the receiver (self, the newly created clone) with initial value 0. Now each clone has its own count slot, and incrementing it with self count = self count + 1 writes to the clone’s own slot without touching the ancestor. The distinction between := (creates a new slot on the receiver) and = (assigns to an existing slot by walking the delegation chain) is the single most common source of bugs in Io prototype hierarchies and the most underlogged category of retainer work: the fix is often a one-character change that replaces = with := in an init method, but diagnosing it requires understanding the full slot lookup algorithm and the delegation chain structure.
Io’s slot system is fully reflective. obj hasSlot("slotName") returns true if obj has a slot named slotName in its own slot table (not on ancestors). obj getSlot("slotName") walks the delegation chain and returns the slot value or nil. obj setSlot("slotName", value) is equivalent to obj slotName := value using a string name computed at runtime. obj updateSlot("slotName", value) is equivalent to obj slotName = value using a string name. obj slotNames returns a List of all slot names on the immediate object (not including inherited slots). The forward slot is a special slot: if a message is sent to an object and no slot by that name is found anywhere in the delegation chain up to Object, Io calls forward on the original receiver with the message as an argument. Overriding forward enables proxy objects, dynamic dispatch tables, and missing-method handlers: MyProxy forward := method(msg, realObject perform(msg)) forwards all unknown messages to realObject. The resend operator inside a method calls the same-named method on the parent prototype, similar to super in class-based languages.
Io’s message model: everything in Io is a message send. 3 + 4 sends the + message with argument 4 to the receiver 3. method(x, x * x) creates a method object that, when activated, binds x to its argument and evaluates x * x. block(x, x * x) creates a block (a closure that captures its enclosing scope rather than rebinding). Methods defined with method use the receiver (self) as their activation context; blocks use their defining scope. Io’s Message object represents the unevaluated AST of a message send. thisMessage inside a method returns the Message object for the current call. thisMessage name returns the string name of the message. thisMessage argAt(0) returns the unevaluated Message for the first argument. thisMessage argAt(0) name returns the name of the first argument’s message. thisMessage sendTo(receiver, locals) sends the message to a different receiver with a different locals scope. This enables full macro-like metaprogramming: a method can inspect the unevaluated AST of its arguments and decide whether and how to evaluate them.
Io’s concurrency model: Coroutines, Actors, Futures, and cooperative scheduling
Io’s concurrency model is built on green threads (Coroutines) rather than OS threads. All Io code runs on a single OS thread by default; the Io scheduler interleaves Coroutines cooperatively, switching only when a Coroutine calls yield, pause, or waitForValue. A Coroutine is created with Coroutine withBlock(aBlock) and started with coroutine resume or implicitly when an asynchronous message is sent. The cooperative scheduling model means that a Coroutine that does not yield runs to completion before any other Coroutine gets CPU time. This is correct for short-lived tasks but wrong for long-running actors that must coexist with other actors — they must insert yield calls at points where interleaving is safe. Coroutine yield suspends the current Coroutine and returns control to the scheduler, which resumes the next runnable Coroutine. Coroutine pause suspends and does not reschedule the current Coroutine; another Coroutine must explicitly resume it. Coroutine currentCoroutine returns the running Coroutine object. Coroutine isCurrent returns true if the receiver is the currently executing Coroutine.
Io’s Actor model builds on Coroutines. An Actor is an object that processes messages in its own Coroutine. Sending @ (an asynchronous message) to any object creates an Actor Coroutine for that object if one does not exist and queues the message for processing. The sender receives a Future object immediately. The Actor’s Coroutine processes queued messages in order, executing each message body and storing the result in the corresponding Future. The sender can call future waitForValue to block its own Coroutine until the Actor has processed the message and the result is available. @@ (fire-and-forget asynchronous send) creates an Actor Coroutine and queues the message but returns nil instead of a Future — the sender receives no result handle and cannot wait for completion. The difference: result := obj @ someMethod(args) creates a Future; result waitForValue blocks the sender’s Coroutine until someMethod completes. obj @@ someMethod(args) creates an Actor and queues the work but the sender continues immediately without a result. Designing Future chains correctly: if multiple asynchronous operations must complete before a final step, send all of them with @ to collect their Futures, then call waitForValue on each Future at the collection point — this allows all asynchronous operations to run concurrently rather than sequentially.
The canonical concurrency hazard in Io Actor retainers: an Actor object whose method modifies a slot also has that slot read by a synchronous caller. In Io’s single-threaded cooperative model, if the Actor’s Coroutine is running, no other Coroutine runs — so a slot read from outside the Actor’s Coroutine will see a consistent state as long as the reading code does not yield between the read and any subsequent use of the value. The retainer work here is identifying the points in the calling code where yielding between a slot read and the use of the read value could allow the Actor to process another message and mutate the slot. The fix is typically to move the read and its use into a single message sent to the Actor (so the Actor processes the read and use atomically in its own Coroutine) rather than having the caller reach into the Actor’s slots directly.
Io’s platform libraries: File for file I/O (f := File with("path/to/file"); f openForReading; lines := f readLines; f close); Socket for TCP networking (s := Socket clone; s connectToHost("localhost", 8080); s write("GET / HTTP/1.0\r\n\r\n"); response := s readToEnd; s close); HttpServer for HTTP services (server := HttpServer clone; server setPort(8080); server handleRequest := method(req, res, res setBody("Hello") send); server start). Io also includes a Sequence type for byte strings with encoding methods, a List type with map/select/detect/inject functional operations, and a Map type for key-value storage. The doString and doFile methods evaluate Io code at runtime: doString("1 + 1") // => 2. Io’s package manager installs community packages; the Importer mechanism handles import "PackageName" statements.
How HourTab tracks Io developer retainer hours
Io retainer work shares the invisible-work problem with all prototype-based language retainers, with the additional challenge that Io’s most common retainer tasks — yield point insertion for cooperative scheduler correctness, := vs = slot discipline audit, Future chain design for concurrent pipelines, forward handler authorship for dynamic dispatch — produce diffs whose surface area is small relative to the analytical work required. Inserting three yield calls at the midpoints of actor computation loops is a diff with three lines; the value is correct cooperative scheduling of all actors, elimination of starvation for all Coroutines in the system, and correct counter totals across all batch runs. Changing count = count + 1 to count := 0 in an init method is a diff with one character; the value is correct per-clone slot isolation, elimination of ancestor prototype mutation, and correct independent counter state for all clones in all future instances. Replacing a sequential synchronous 4-stage pipeline with an @-based Future chain with a single terminal waitForValue is a diff with ten lines; the value is correct concurrent execution of all four stages, elimination of blocking on stage N while stages N+1 through N+3 wait idle, and a fourfold reduction in pipeline latency.
HourTab gives Io developers a public retainer-hours URL they send to clients — typically research groups using Io as a language design substrate, game studios using Io as an embedded scripting engine, and embedded systems teams using Io for its minimal memory footprint and message-based architecture — at the start of an engagement. For Io retainers, each work log entry should name the mechanism (Coroutine yield point insertion for cooperative scheduler correctness; @ asynchronous message send and Future pipeline design; @@ fire-and-forget send design; waitForValue blocking chain design; Actor state serialization design; Object clone hierarchy audit for shared-slot mutation bugs; := vs = slot discipline correction; init method design for per-clone slot initialization; forward handler authorship for unknown message interception; hasSlot/getSlot/setSlot/updateSlot introspection; Message argAt/setName/sendTo introspection design; File/Socket/HttpServer platform integration), the specific coroutine or prototype name and the scheduling or mutation problem, and the before/after observable metric. Io retainers are often compared to Smalltalk developer retainers for message-passing object system work, to Lua developer retainers for embedded scripting platform work, and to Scheme developer retainers for minimalist language platform engineering. The distinction from Smalltalk is the prototype model: Io has no classes, only delegation chains, which makes the slot discipline (:= vs =) and init pattern the primary source of structural bugs. HourTab’s work log makes the yield insertion and slot discipline repair visible to clients who would otherwise see only the symptom — dropped counter increments or cross-clone state corruption — and not understand why the fix required understanding Io’s cooperative scheduler and prototype delegation semantics.
Track Io developer retainer hours without the status emails
HourTab gives Io developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: Io developer retainers
What does an Io developer on retainer typically do?
An Io developer on monthly retainer covers four principal service areas: prototype hierarchy and slot design (Object clone chain audit for shared-slot mutation bugs; := local slot set vs = existing-slot update analysis; init method design for per-clone slot initialization; forward handler authorship for unknown message interception; hasSlot/getSlot/setSlot/updateSlot introspection; resend parent delegation); concurrency model design (Coroutine withBlock/resume scheduling with correct yield point placement; @ asynchronous message send and Future pipeline design; @@ fire-and-forget send; waitForValue blocking chain; Actor state serialization); message introspection and metaprogramming (Message name/argAt/setName/sendTo introspection; doString/doFile dynamic eval; method vs block scope distinction; slot accessor generation); and platform engineering (File read/write; Socket TCP networking; HttpServer web services; package system; C FFI integration).
What Io work is most commonly underlogged in a retainer?
Cooperative multitasking yield audit (3 concurrent actors with no yield calls; first actor held scheduler; other actors received 0 increments for 5 batches/day; added yield at computation midpoints; missed increments: 5 batches/day → 0; 12–22 hrs invisible in coroutine scheduling trace, yield point analysis, and actor body restructuring), prototype shared-slot mutation repair (Counter clone without init redefining count with :=; = wrote to ancestor slot; all clones shared counter; added init with count := 0; cross-clone state corruption: 8 sessions/day → 0; 10–18 hrs invisible in prototype hierarchy audit and := vs = distinction analysis), and Future-based pipeline design (sequential synchronous 4-stage pipeline blocking 400ms per invocation; redesigned with @ sends and terminal waitForValue; pipeline latency: 400ms → 95ms; 8–16 hrs invisible in Future chain design and coroutine interleaving verification).
What are typical Io developer retainer rates?
Entry-level Io developers (1–2 years, Object/Lobby basics, simple clone-and-override patterns, surface-level :=/= discipline, basic method/block definition, if/then/else/while control flow) bill at $60–$110/hr. Mid-level Io engineers (2–4 years, Coroutine withBlock/resume scheduling with correct yield points, @/@@ asynchronous sends, Future creation and waitForValue chaining, forward handler authorship, Message argAt/setName introspection, Socket/File platform integration) bill at $105–$185/hr. Senior Io architects (4–8 years, full actor-based concurrency, HttpServer web service design, C FFI integration, doString/doFile metaprogramming, complex prototype hierarchy design for DSL construction, Io interpreter embedding) bill at $155–$280/hr. Monthly retainer ranges: $2,000–$4,500/mo advisory (15–25 hrs), $6,500–$16,000/mo for full Io platform engagements.
What should an Io developer retainer agreement include?
An Io developer retainer agreement should specify: prototype hierarchy scope (Object clone chain audit for shared-slot mutation bugs; := vs = slot discipline; init method design; forward handler authorship; hasSlot/getSlot/setSlot/updateSlot introspection; resend parent delegation); concurrency scope (Coroutine withBlock/resume with correct yield points; @ Future pipeline design; @@ fire-and-forget; waitForValue blocking; Actor state serialization); message introspection scope (Message name/argAt/setName/sendTo; doString/doFile dynamic eval; method vs block scope); platform scope (File read/write; Socket TCP; HttpServer HTTP; package system; C FFI); and hour logging format (prototype name; slot mutation type; coroutine count; yield insertion point; Io version).
How should Io developer retainer hours be logged?
Log each Io retainer session with: advisory category (Coroutine yield point insertion; @ Future pipeline design; @@ fire-and-forget; waitForValue blocking chain; Actor state serialization; Object clone hierarchy audit; := vs = slot correction; init method design; forward handler authorship; hasSlot/getSlot/setSlot/updateSlot introspection; Message argAt/setName introspection; doString/doFile eval; File/Socket/HttpServer platform integration; C FFI binding), the specific actor or prototype name and the scheduling or mutation problem (3 concurrent actors with no yield points; missed increments: 5 batches/day → 0 after yield insertion at computation midpoints), and the before/after metric (missed increments per batch: 5 → 0; cross-clone state corruptions per day: 8 → 0; pipeline latency: 400ms → 95ms). Include Io version, OS, and whether the fix required yield insertion, := correction, forward handler addition, or Future chain redesign.