DEV Community

Cover image for Managed Coroutines in TypeScript: How to Gain Control over Asynchrony and Memory. A Closer Look
Devs Daddy
Devs Daddy

Posted on

Managed Coroutines in TypeScript: How to Gain Control over Asynchrony and Memory. A Closer Look

Modern JavaScript/TypeScript today provides a powerful tool for asynchronous programming that everyone is familiar with: async/await, based on Promises. It allows you to write consistent code and easily handle errors. However, this simplicity conceals serious limitations that become critical in highly loaded systems, games, complex user interfaces, and applications that require predictable performance.

Imagine a server processing thousands of requests simultaneously, all competing for CPU time. How can you guarantee that a client request will complete before background synchronization? How can you cancel a long-running operation if the client disconnects? How can you avoid garbage collection pauses when creating millions of temporary objects? Native async/await doesn't answer these questions - it merely wraps asynchronous operations in a convenient syntax.

In this article, we'll explore how cooperative coroutines on generators solve these problems, and learn from my ts-adaptive-coroutines library, breaking down each aspect brick by brick, looking at a tool that adds adaptive priorities, memory arenas, channels, semaphores, and multithreading to TypeScript.

For those who aren't interested in diving into theory but want to get hands-on, you can explore the library.

For the rest of you, we begin our big journey into the complex world of coroutines and memory management.


Why async/await isn't always enough?

A visual illustration of async/await on complex systems

async/await is syntactic sugar over Promise. It allows you to write sequential code, but:

  • No priorities: all asynchronous tasks are equal. It's impossible to specify that one task should execute before another in case of concurrency.
  • Cancellation is inconvenient at high nesting depths: you need to manually set up an AbortController and check it for each asynchronous operation.
  • Under-the-hood memory: you can't manually manage it, and without understanding how it works, some await create closures that are collected by the GC. With a large number of operations, this leads to frequent garbage collections, which can freeze the thread for tens of milliseconds.
  • Thread blocking: If a task performs long calculations without await, it completely blocks the event loop, preventing other tasks from executing.

Coroutines solve these problems through cooperative multitasking and declarative control. We'll explore this step-by-step below.


A few words about coroutines

What is Coroutines?

Coroutines vs Functions

A coroutine is a function that can pause its execution at certain points using yield and transfer control to other code (the scheduler). Unlike async/await, which only pauses on asynchronous operations (Promises), a coroutine can yield the processor at any time, even when performing synchronous computations.

A generator is a function that can return multiple values ​​using yield . Within my library, each coroutine is represented by a generator. When a generator calls yield with an effect, it pauses and returns that effect to the scheduler.

function* example() {
  const a = yield 1;
  console.log(a);     // will returned after next()
  const b = yield 2;
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

The ts-adaptive-coroutines library, which we'll use to explore aspects of working with coroutines, uses generators as the basis for building managed coroutines. The scheduler stores multiple generators and runs them in turn, allowing them to cooperatively share CPU time.

Why choose coroutines?

By choosing generators over pure async/await, you gain complete control over the switching points. async/await automatically pauses only on await, and we can't interfere with this process. Generators, on the other hand, allow you to explicitly specify when a coroutine is ready to yield control and return special effects instructions for the scheduler.

Effects are simple objects that describe what a coroutine wants to do: wait, start another coroutine, cancel something, and so on. This declarative approach separates intent from implementation. The scheduler can interpret effects differently depending on the context, which allows for flexibility (for example, adaptive priorities).

This design has already been proven in libraries like redux-saga and effection, but we're adding unique features for those who want full control: memory arenas to reduce GC load and multithreading support.


Library architecture. Everything you need to effectively work with coroutines.

Before we dive further into the theory and practice of working with coroutines, let's take a look at the architecture of my library so we can examine each aspect step by step.

The library consists of several modules that are closely related, but can also be used independently:

  • Scheduler is the central component that manages the coroutine queue, priorities, timers, and execution.
  • Coroutine is a wrapper around a stateful generator, a Promise for external awaiting, and cancellation methods.
  • Effect is a system of declarative instructions for coroutines, including yield, sleep, fork, all, race, call, awaitPromise, yieldEvery, and setPriority.
  • Arena is a memory manager for temporary data.
  • Pool is a pool of objects for reuse.
  • Channel and Semaphore are synchronization primitives.
  • DistributedScheduler is a scheduler for multithreaded computations based on Workers.

Step-by-step description of the work:

  1. Creating a scheduler. Using createScheduler(options), a Scheduler instance is created. It configures priorities, the arena, pools, and queues.
  2. Spawning a coroutine. Calling scheduler.spawn(factory, options) creates a new coroutine object wrapping the generator and places it in the ready queue.
  3. Scheduler loop. The scheduler loops through the coroutine with the highest effective priority, executes it until the next yield (or until it completes/suspends), and then repeats.
  4. Processing effects. If the coroutine returns an effect, the scheduler interprets it: queues it for a timer, starts child coroutines, awaits the Promise, and so on.
  5. Completion. When the generator completes (done: true), the coroutine is marked as Completed, its Promise is resolved, and resources (including the arena portion) are freed.

This way, the entire process is predictable and manageable, and we can move forward and look at every aspect of the work in detail.


Effects: Declarative Control

A regular coroutine (generator) can call Promise, setTimeout, and fetch directly, but then the scheduler can't manage these operations, isn't aware of pauses, can't cancel awaits, limit concurrency, or change the execution strategy. If a coroutine yield an object describing what to do (for example, "wait 100ms" or "start another coroutine"), the scheduler gains complete control.

Adaptability means that the scheduler can dynamically change its behavior: for example, under high load, ignore yield (continue execution), while under low load, actually yield; or choose different fork strategies depending on available resources.

My library implements precisely this adaptive approach. Next, let's take a look at the effects available in my library so you can better understand their purpose.

Main effects

yieldMain() is used to yield execution to other coroutines. It is used for cooperative multitasking in long loops.

function* longLoop() {
  for (let i = 0; i < 1_000_000; i++) {
    // heavy job, suspend and take access to other coroutines
    if (i % 1000 === 0) yield yieldMain();
  }
}
Enter fullscreen mode Exit fullscreen mode

sleep(ms), pause the coroutine for the specified time.

yield sleep(500);
Enter fullscreen mode Exit fullscreen mode

fork(factory) starts a child coroutine. Returns its handle (an object with an id, promise, cancel, and setPriority).

const child = yield fork(() => worker());
console.log('Child coroutine id:', child.id);
Enter fullscreen mode Exit fullscreen mode

cancel(handleId?) cancels the coroutine by id (if not specified, then the current one). Cancellation calls the generator's return(), allowing the finally blocks to execute.

yield cancel(child.id);
Enter fullscreen mode Exit fullscreen mode

all(factories) - will launch several coroutines in parallel and wait for them all. Returns an array of results.

const results = yield all([() => task1(), () => task2()]);
Enter fullscreen mode Exit fullscreen mode

race(factories) is used to start several coroutines, wait for the first one to complete, and cancel the rest.

const first = yield race([() => timeout(5000), () => fetchData()]);
Enter fullscreen mode Exit fullscreen mode

call(fn) - to call a function that can return a Promise or a simple value. Similar to await, but controlled by the scheduler.

const data = yield call(() => fetch('/api').then(r => r.json()));
Enter fullscreen mode Exit fullscreen mode

awaitPromise(promise) will wait for complete Promise.

yield awaitPromise(somePromise);
Enter fullscreen mode Exit fullscreen mode

yieldEvery(n, counter?) - to yield every n calls. Useful in loops where yielding on every iteration isn't necessary.

const counter = { count: 0 };
for (const item of items) {
  process(item);
  yield yieldEvery(100, counter);
}
Enter fullscreen mode Exit fullscreen mode

setPriority(p) - change the base priority of the current coroutine.

yield setPriority(10);
Enter fullscreen mode Exit fullscreen mode

How the scheduler handles effects

When a coroutine returns an effect, the scheduler looks at its type and performs the appropriate action:

  • yield: push the coroutine to the end of the queue.
  • sleep: add to the sleep heap with a timeout for waking up.
  • fork: create a new coroutine and return its object.
  • call / awaitPromise: subscribe to a Promise and resume the coroutine when it resolves.
  • all / race: start multiple coroutines and coordinate them.
  • cancel: find a coroutine and call its doCancel().
  • yieldEvery: check the counter and either yield or continue.
  • setPriority: update the priority and continue execution of the coroutine.

Effects make code declarative and easily testable, allowing, for example, mocking effects or checking their sequence.


Scheduler and priorities

Scheduler for Typescript

Let's move on to the heart of the library and the approach itself - the scheduler. The scheduler, like a task manager, manages the lifecycle of coroutines: it decides which coroutine to run next, when to suspend or resume, how to handle effects, and how to allocate resources. Without it, generators are simply functions that must be manually called, passed values, and exceptions handled.

Why can't you do without a scheduler?

Generators don't execute on their own - they need to be constantly called with next(). You can write a simple loop that iterates over generators, but then:

  • No priorities: all coroutines will execute in FIFO order.
  • No timers: you need to manually manage setTimeout and queues.
  • No cancellation: it's difficult to properly stop a generator and handle finally.
  • No time slicing: a single coroutine can take up a thread for a long time.
  • No proper memory management.

In our case, the scheduler contains:

  • A Binary Heap of ready coroutines, sorted by effective priority and setup time.
  • A Sleep Heap of sleeping coroutines, sorted by the time they will be woken up.
  • A Map of active coroutines, activeMap, for quick lookup by ID.
  • A set of paused coroutines, paused manually.
  • An arena for temporary data.
  • A tracer for collecting metrics.

The basic scheduler loop looks like this:

  1. Process awakened coroutines.
  2. Recalculate priorities (if the aging interval has passed).
  3. Take the coroutine with the highest effective priority.
  4. Run it until the next yield or completion.
  5. If there is nothing to run but there are sleeping coroutines, wait until the next wakeup (within a constraint).
  6. Repeat.

About priorities and preventing starvation

Each coroutine in the library has a base priority (a number). By default, it is set upon creation, but can be changed using the setPriority effect.

However, simply comparing base priorities would starve low-priority tasks. Therefore, we use an adaptive strategy: effective priority = base priority + a bonus based on waiting time. The formula is:

boost = boostMax * (1 - exp(-lambda * waitMs))
effective = base + boost
Enter fullscreen mode Exit fullscreen mode

The longer a coroutine waits, the higher its effective priority, and at some point it will overtake high-priority tasks. Parameters (lambda, boostMax, recalculation interval) are configured through the scheduler options.

This ensures that even a background task will eventually get a chance to run.


Memory Management: Arenas and Pools

Arenas and Pools for Typescript

In JavaScript, memory is managed automatically, but this comes at a cost. When many short-lived objects are created (for example, with every yield or await), the garbage collector is forced to run frequently, which can cause noticeable pauses.

Arenas and Pools come into play to solve such problems. I've also implemented them in the library so you don't have to do it manually. Let's take a look.

Arena

An Arena is a pre-allocated block of ArrayBuffer memory from which you can manually allocate chunks for temporary data. All allocations are sequential, and deallocation is accomplished by resetting the pointer to the beginning. This is incredibly fast and creates no garbage collection.

Arena example:

const arena = new Arena(1024 * 1024);   // Create with 1 МБ
const offset = arena.allocAligned(256); // Allocate 256 bytes
const view = arena.view(offset, 256);   // View data
view.setFloat64(0, 3.14);               // Or set data
// ...
arena.reset(0);                         // Release all memory
Enter fullscreen mode Exit fullscreen mode

A real-world example: inside a coroutine, you need to serialize data into binary format. Instead of creating multiple small Uint8Arrays, you can allocate one large chunk in an arena, write it there, use it, and then automatically roll back the arena after the coroutine completes.

To improve performance, I also created WasmArena, which uses WebAssembly for allocations. If multithreading support is needed, an arena on SharedArrayBuffer with atomic operations can be used.

Object Pools

In addition to arenas, the library provides a Pool for reusing objects. This is useful for frequently created instances, such as coroutines or stack frames.

Pool example:

// Create new pool
const pool = new Pool<MyObject>({
  create: () => new MyObject(),
  reset: (obj) => obj.reset()
});

// Get object from pool
const obj = pool.acquire();
// ... Use our object
pool.release(obj); // Reset object and return to pool
Enter fullscreen mode Exit fullscreen mode

Pools reduce allocations and reduce the load on the GC.

Alternatives: you can avoid memory management altogether and rely on the garbage collector. This is fine for small applications, but under heavy loads, GC pauses can become a problem.

How is this integrated into the library?

The scheduler uses a pool for coroutine objects and stack frames. Each coroutine can allocate temporary data in an arena, and upon termination, the arena is automatically rolled back to the saved state. This ensures deterministic memory management without the need for a GC.


Channels and semaphores

Channels

A channel is a way to exchange messages between coroutines. They support asynchronous awaiting, so a producer can wait for a consumer to consume an element (or vice versa).

Channels are particularly useful for organizing pipelines and processing data streams.

Example: a data processing pipeline: one coroutine reads from a file, another processes it, and a third writes the result. Channels connect them, ensuring smooth transfer without race conditions.

Channel example:

// Create data channel
const channel = new Channel<number>(fixedBuffer(10));

// Set channel data
await channel.put(42);

// Get channel data from queue
const value = await channel.take(); // 42
Enter fullscreen mode Exit fullscreen mode

Moreover, my library supports three buffering strategies:

  • fixedBuffer(capacity) - a classic queue: put blocks when overflowing.
  • slidingBuffer(capacity) - when overflowing, old elements are evicted.
  • droppingBuffer(capacity) - when overflowing, new elements are discarded.

Semaphores

Semaphores limit the number of concurrently executing coroutines. For example, we don't want to make more than 10 concurrent requests to an external API. A semaphore makes this easy to implement, and the remaining coroutines will wait their turn.

Semaphore example:

// Create new semaphore
const sem = new Semaphore(3);

yield call(() => sem.acquire());
try {
  // only 3 coroutines can be here
} finally {
  sem.release();
}
Enter fullscreen mode Exit fullscreen mode

Alternatives: You can use regular arrays and setTimeout, but then you'll have to manually manage wait queues and notifications, which is cumbersome and error-prone. Other libraries (async.js, p-limit) provide similar primitives, but they aren't integrated with coroutines and priorities.


Multithreading with workers

Multithreading in TypeScript

JavaScript in the browser and Node.js are single-threaded by default. This means that all coroutines (and asynchronous operations) run in a single thread, and CPU-intensive tasks can block the event loop. To utilize multiple CPU cores, you need to use workers (Web Workers or Node.js Worker Threads).

Multithreading allows you to:

  • Perform heavy computations in parallel without blocking the UI or request processing.
  • Isolate coroutines in separate threads (for example, for security or stability).
  • Distribute the load across cores.

Coroutines are especially useful when multithreading is required. To leverage multiple CPU cores, my library provides DistributedScheduler. It can operate in two modes:

  • Multiple local schedulers in a single thread simulate parallelism (for task isolation).
  • Real workers (Web Worker or Node.js Worker) – each with its own scheduler.

A factory registry is used to securely transfer tasks to workers. No eval - only pre-registered functions.

// Register our method
registerFactory('fetchData', (url) => function* () { /* ... */ });

// Run via Workers
const distSched = new DistributedScheduler({ useWorkers: true, size: 4 });
const handle = distSched.spawnOnWorkerByName('fetchData', ['https://api.example.com']);
const result = await handle.promise;
Enter fullscreen mode Exit fullscreen mode

This approach provides isolation and security, and allows for load balancing between threads.


About React and coroutines

React provides hooks for managing state (useState) and side effects (useEffect). For asynchronous operations, useEffect is typically used with the async function, but this creates problems:

  • Cancellation on unmounting - you must manually use AbortController and check the flag.
  • Pause/resume - difficult to implement (e.g., when minimizing a tab).
  • Priorities - it's impossible to specify which task is more important.
  • Coordination of multiple coroutines - no built-in primitives (e.g., all, race).
  • Memory leaks - frequent starts/stops can accumulate timers and closures.

Coroutines solve these problems: they can be suspended, canceled, prioritized, and their lifecycle is automatically tied to the component's lifecycle.

As an interesting aside, I've included an example of integrating coroutines into React as part of the library (optional). They allow you to manage coroutines within components, automatically tying their lifecycle to the component's lifecycle.

An example of a basic component in the general context of coroutines:

import { useCoroutine, SchedulerProvider } from 'ts-adaptive-coroutines/react';

// Coroutines simple component example
function MyComponent() {
  const { status, result, start, cancel } = useCoroutine(
    () => function* () {
      yield sleep(1000);
      return 'Hello';
    },
    { autoStart: true }
  );
  return <div>{status}: {result}</div>;
}
Enter fullscreen mode Exit fullscreen mode

An example of polling with a pause when the tab is invisible:

function PollingComponent() {
  // Creates a coroutine
  const { status, start, pause, resume } = useCoroutine(
    () => function* () {
      while (true) {
        const data = yield call(() => fetch('/api/status').then(r => r.json()));
        setData(data);
        yield sleep(5000);
      }
    },
    { autoStart: true }
  );

  useEffect(() => {
    const onVisibility = () => {
      if (document.hidden) pause(); else resume();
    };
    document.addEventListener('visibilitychange', onVisibility);
    return () => document.removeEventListener('visibilitychange', onVisibility);
  }, [pause, resume]);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Example of canceling a request when leaving a page:

function DataLoader() {
  // Create a Coroutine
  const { result, error, cancel } = useCoroutine(
    () => function* () {
      const controller = new AbortController();
      yield fork(() => function* () {
        yield call(() => fetch('/data', { signal: controller.signal }));
        controller.abort();
      }());
      // ...
    },
    { autoStart: true }
  );

  // Then unmounted - coroutine will be stoped automatically
  return <div>{result}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Thus, coroutines make asynchronous code in React more manageable and predictable than simple async/await.


Comparison with other approaches and libraries

Finally, let's look at existing solutions and compare them with the out-of-the-box ts-adaptive-coroutines library:

Feature ts-adaptive-coroutines async / await redux-saga effection RxJs
Model Generators + Effects + Scheduler + Promises Promises Generators + Middleware Generators + Hierarchy Reactive flows
Priority control Adaptive priority Scheduler only
Memory management Arenas + Pools ❌ GC ❌ GC ❌ GC ❌ GC
Multithreading Workers, SharedArray Buffer, Work-Stealing Manual
Cancellation Hierarchy based AbortController cancelled effect Unsubscribe
Observability Tracing with export to Open-Telemetry Manual DevTools Partial Manual
React integration Optional hooks No react-redux-saga rxjs-hooks

Thus, ts-adaptive-coroutines is unique in its combination of control over execution, memory, and multithreading, making it a powerful tool for demanding applications.

Basic benchmarks can be found in the repository.


Conclusion

Today we explored how concurrency and coroutines work in TypeScript, using the ts-adaptive-coroutines library as an example. It provides complete control over asynchronous code. Its well-thought-out architecture, combining generators, effects, adaptive priorities, memory arenas, and concurrency support, enables the creation of predictable, performant, and easily debuggable systems.

Typical use cases:

  • Server applications: request prioritization (VIP clients are served faster), database concurrency limiting, data processing pipelines.
  • Games and simulations: managing multiple agents, each a coroutine with its own state and priority. Cooperative multitasking ensures that all agents are updated uniformly.
  • Interactive interfaces: background tasks (polling, autosaving), paused animations, cancellation of operations when state changes.
  • Data stream processing: channels allow you to build complex pipelines with backpressure without creating a timer avalanche.

We've covered all the key aspects, from basic concepts to integration with React and workers. Now you're ready to use this library in your projects—whether it's a high-load server, a game, or a complex interface.


GitHub | NPM


I would be glad to receive your comments and questions.

Top comments (0)