Coroutines

Finally, proper async cancellation without the AbortController nightmare. Manage complex async workflows with confidence, not chaos.

The Async Cancellation Crisis

You've lost hours debugging memory leaks from uncancelled requests. We've all been there.

The JavaScript Reality: A House of Cards

// The code every team writes... and regrets
let cancelled = false;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

async function loadDashboard() {
  try {
    // Fetch user data
    const userReq = fetch('/api/user', { signal: controller.signal });
    if (cancelled) return; // Manual check #1

    // Fetch analytics (oops, forgot the signal!)
    const analyticsReq = fetch('/api/analytics');

    // Fetch notifications
    const notifReq = fetch('/api/notifications', { signal: controller.signal });
    if (cancelled) return; // Manual check #2 (getting tired yet?)

    const [user, analytics, notif] = await Promise.all([
      userReq, analyticsReq, notifReq
    ]);

    if (cancelled) return; // Manual check #3 (did I miss any?)

    // Process results...
    clearTimeout(timeoutId); // Don't forget this!

  } catch (e) {
    if (e.name === 'AbortError') return; // Is this all of them?
    if (e.name === 'TimeoutError') return; // Wait, what throws this?
    clearTimeout(timeoutId); // Did I already clear this?
    throw e;
  }
}

// Component unmounts... memory leak incoming!
// Forgot to: cancelled = true, controller.abort(), clearTimeout()
// Your users' browsers are now running zombie requests

The Coroutine Solution: Structured Concurrency

import { coroutineScope, withTimeout, launch } from 'kotlinify-ts/coroutines';

async function loadDashboard() {
  // Everything in this scope is automatically managed
  // Notice: No 'async' keywords needed! The library handles async implicitly
  return withTimeout(5000, () =>
    coroutineScope((scope) => {
      // Launch parallel operations - all automatically cancellable
      const userJob = scope.async(() => fetch('/api/user').then(r => r.json()));
      const analyticsJob = scope.async(() => fetch('/api/analytics').then(r => r.json()));
      const notifJob = scope.async(() => fetch('/api/notifications').then(r => r.json()));

      // If ANY fails or times out, ALL are cancelled
      // If the scope is cancelled, ALL are cancelled
      // No manual checks. No forgotten cleanup. No leaks.

      const [user, analytics, notif] = await Promise.all([
        userJob.await(),
        analyticsJob.await(),
        notifJob.await()
      ]);

      return { user, analytics, notif };
    })
  );
}

// Component unmounts?
const job = launch(() => loadDashboard());
onUnmount(() => job.cancel()); // ONE line cancels EVERYTHING

The Hidden Costs of Bad Async

  • • Memory leaks from uncancelled requests
  • • Race conditions from improper cleanup
  • • Zombie timers eating CPU cycles
  • • State updates after component unmount
  • • "Can't perform state update on unmounted component"
  • • Users complaining about slow, laggy apps

What Coroutines Give You

  • • Automatic cancellation propagation
  • • Parent-child job relationships
  • • Built-in timeout handling
  • • Resource cleanup guarantees
  • • Error boundaries that make sense
  • • Happy users with responsive apps

Core Types

The building blocks of structured concurrency

Job

A handle to a running coroutine that can be cancelled, joined, and monitored.

import { Job, launch } from 'kotlinify-ts/coroutines';

// Every launched coroutine returns a Job
const job: Job = launch(function() {
  console.log("Running in job context");
  console.log("Is active?", this.isActive);
});

// Job states
job.isActive;    // true while running
job.isCompleted; // true when finished successfully
job.isCancelled; // true if cancelled

// Job operations
job.cancel("Optional reason");  // Cancel the job
await job.join();               // Wait for completion

// React to cancellation
const job2 = launch(function() {
  this.onCancel(() => {
    console.log("Cleaning up resources");
  });

  // Long running operation
  this.ensureActive(); // Throws CancellationError if cancelled
});

Deferred

A Job that produces a value - like a Promise with cancellation support.

import { asyncValue as async, Deferred } from 'kotlinify-ts/coroutines';

// async returns a Deferred<T>
const deferred: Deferred<number> = async(async () => {
  await delay(1000);
  return 42;
});

// Deferred extends Job, so has all Job capabilities
deferred.cancel();
deferred.isActive;

// Get the value
const value = await deferred.await(); // Returns 42

// Check if value is available without waiting
const completed = deferred.getCompleted(); // undefined if not ready

Try it yourself:

// Launch a simple coroutine
const job = launch(async () => {
  await delay(100);
  console.log('Job completed!');
  return 'done';
});

console.log('Job launched, waiting...');
await job.join();
console.log('Job finished');

// Use async for values
const result = async(async () => {
  await delay(50);
  return 42;
});

const value = await result.await();
console.log('Result:', value);

CoroutineScope

A scope that manages multiple coroutines and propagates cancellation.

import { CoroutineScope } from 'kotlinify-ts/coroutines';

const scope = new CoroutineScope();

// Launch children in the scope
const job1 = scope.launch(function() {
  console.log("First coroutine");
});

const deferred = scope.async(async () => {
  await delay(1000);
  return "result";
});

// Cancel all children at once
scope.cancel(); // Cancels job1 and deferred

// Wait for all children
await scope.joinAll();

Launching Coroutines

Start concurrent operations with automatic lifecycle management

import { launch, asyncValue as async, delay } from 'kotlinify-ts/coroutines';

// launch: Fire-and-forget coroutine
const job = launch(function() {
  console.log("Starting work");
  delay(1000).then(() => {
    console.log("Work complete");
  });

  // Access job context via 'this'
  if (this.isActive) {
    console.log("Still active!");
  }
});

// async: Coroutine that returns a value
const deferred = async(() => {
  return delay(500).then(() => ({ id: 1, name: "User" }));
});

const user = await deferred.await();

// Wrap existing promises
import { wrapAsync } from 'kotlinify-ts/coroutines';

const wrappedFetch = wrapAsync(fetch('/api/data'));
wrappedFetch.cancel(); // Can cancel even native promises!

Structured Concurrency

Parent-child relationships ensure no coroutine is left behind

import { coroutineScope, supervisorScope, launch, delay } from 'kotlinify-ts/coroutines';

// coroutineScope: Waits for all children before returning
const result = await coroutineScope(async (scope) => {
  // Launch parallel operations
  scope.launch(function() {
    return delay(1000).then(() => {
      console.log("Operation 1 complete");
    });
  });

  scope.launch(function() {
    return delay(500).then(() => {
      console.log("Operation 2 complete");
    });
  });

  // This line executes immediately
  console.log("All launched");

  // Function waits for all children before returning
  return "All done";
}); // Returns only after both operations complete

// Error in child cancels all siblings
try {
  await coroutineScope(async (scope) => {
    scope.launch(function() {
      return delay(1000).then(() => {
        console.log("This won't print");
      });
    });

    scope.launch(function() {
      return delay(100).then(() => {
        throw new Error("Failed!");
      });
    });
  });
} catch (e) {
  console.log("Parent caught:", e.message); // "Failed!"
}

// supervisorScope: Children fail independently
await supervisorScope(async (scope) => {
  scope.launch(function() {
    throw new Error("Child 1 failed");
  });

  scope.launch(function() {
    return delay(100).then(() => {
      console.log("Child 2 continues!"); // Still runs
    });
  });
});

Timeouts

Automatically cancel operations that take too long

import { withTimeout, withTimeoutOrNull, TimeoutError } from 'kotlinify-ts/coroutines';

// withTimeout: Throws TimeoutError if exceeds time limit
try {
  const data = await withTimeout(5000, () => fetchLargeDataset());
  console.log("Got data:", data);
} catch (e) {
  if (e instanceof TimeoutError) {
    console.error("Operation timed out after 5 seconds");
  }
}

// withTimeoutOrNull: Returns null instead of throwing
const result = await withTimeoutOrNull(3000, () => slowOperation());

if (result === null) {
  console.log("Operation timed out, using cached data");
  return cachedData;
} else {
  return result;
}

// Combine with coroutines for complex timeout scenarios
await coroutineScope(async (scope) => {
  const job1 = scope.async(() =>
    withTimeout(2000, () => fetchUserData())
  );

  const job2 = scope.async(() =>
    withTimeoutOrNull(1000, () => fetchOptionalData())
  );

  const [userData, optionalData] = await Promise.all([
    job1.await(),
    job2.await()
  ]);

  return { userData, optionalData: optionalData || defaults };
});

Cancellation Patterns

Handle cancellation gracefully throughout your async operations

import { launch, delay, CancellationError, coroutineScope } from 'kotlinify-ts/coroutines';

// Parent-child relationships require a scope
await coroutineScope(async (scope) => {
  const parent = scope.launch(function() {
    return coroutineScope(async (childScope) => {
      const child = childScope.launch(function() {
        return delay(5000).then(() => {
          console.log("Child completed"); // Never runs if cancelled
        }).catch(e => {
          if (e instanceof CancellationError) {
            console.log("Child was cancelled");
          }
        });
      });

      return delay(1000).then(() => {
        parent.cancel(); // Cancels both parent and child
      });
    });
  });
});

// Check cancellation at critical points
const job = launch(function() {
  return fetchData()
    .then(data => {
      this.ensureActive(); // Throws if cancelled
      return processData(data);
    })
    .then(processed => {
      this.ensureActive(); // Check again
      return saveResults(processed);
    });
});

// Register cleanup callbacks
const connection = launch(function() {
  const ws = new WebSocket(url);

  this.onCancel(() => {
    console.log("Closing websocket");
    ws.close();
  });

  // Use connection...
  return handleMessages(ws);
});

// Later...
connection.cancel(); // Cleanup runs automatically

Real-World Examples

Practical patterns for production applications

Parallel Data Loading

import { coroutineScope, withTimeout } from 'kotlinify-ts/coroutines';

async function loadDashboard(userId: string) {
  return coroutineScope(async (scope) => {
    // Launch all requests in parallel
    const userJob = scope.async(() =>
      withTimeout(3000, () => fetchUser(userId))
    );

    const ordersJob = scope.async(() =>
      withTimeout(5000, () => fetchOrders(userId))
    );

    const analyticsJob = scope.async(() =>
      withTimeoutOrNull(2000, () => fetchAnalytics(userId))
    );

    // Wait for all to complete
    const [user, orders, analytics] = await Promise.all([
      userJob.await(),
      ordersJob.await(),
      analyticsJob.await()
    ]);

    return {
      user,
      orders,
      analytics: analytics || { visits: 0, revenue: 0 }
    };
  });
}

Retry with Timeout

import { launch, delay, withTimeoutOrNull } from 'kotlinify-ts/coroutines';

async function fetchWithRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 3,
  timeoutMs = 5000
): Promise<T | null> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const result = await withTimeoutOrNull(timeoutMs, operation);

    if (result !== null) {
      return result;
    }

    if (attempt < maxRetries) {
      console.log(`Attempt ${attempt} timed out, retrying...`);
      await delay(1000 * attempt);
    }
  }

  return null;
}

// Usage
const data = await fetchWithRetry(
  () => fetch('/api/data').then(r => r.json()),
  3,
  5000
);

Background Task Manager

import { CoroutineScope, delay } from 'kotlinify-ts/coroutines';

class BackgroundTaskManager {
  private scope = new CoroutineScope();

  startPeriodicSync(intervalMs: number) {
    return this.scope.launch(function() {
      const loop = async () => {
        while (this.isActive) {
          try {
            await syncData();
            await delay(intervalMs);
          } catch (error) {
            console.error("Sync failed:", error);
            await delay(5000);
          }
        }
      };
      return loop();
    });
  }

  startEventListener() {
    return this.scope.launch(function() {
      const events = new EventSource('/events');

      this.onCancel(() => events.close());

      events.onmessage = (e) => {
        if (this.isActive) {
          handleEvent(JSON.parse(e.data));
        }
      };

      return new Promise(() => {});
    });
  }

  async shutdown() {
    console.log("Shutting down background tasks...");
    this.scope.cancel("Application shutdown");
    await this.scope.joinAll();
    console.log("All tasks stopped");
  }
}

const manager = new BackgroundTaskManager();
manager.startPeriodicSync(30000);
manager.startEventListener();

// On app shutdown
process.on('SIGTERM', () => manager.shutdown());

Error Types

Built-in error types for async operations

import { CancellationError, TimeoutError } from 'kotlinify-ts/coroutines';

// CancellationError: Thrown when a job is cancelled
try {
  const job = launch(function() {
    return delay(1000).then(() => {
      this.ensureActive(); // Throws if cancelled
    });
  });

  job.cancel("User navigated away");
  await job.join();
} catch (e) {
  if (e instanceof CancellationError) {
    console.log("Job was cancelled:", e.message);
  }
}

// TimeoutError: Thrown when operation exceeds time limit
try {
  await withTimeout(1000, () => delay(2000));
} catch (e) {
  if (e instanceof TimeoutError) {
    console.log("Operation timed out:", e.message);
  }
}

// Distinguish between different error types
async function robustFetch(url: string) {
  try {
    return await withTimeout(5000, () => fetch(url));
  } catch (e) {
    if (e instanceof TimeoutError) {
      return { error: 'timeout', cached: getCached(url) };
    } else if (e instanceof CancellationError) {
      return { error: 'cancelled' };
    } else {
      return { error: 'network', message: e.message };
    }
  }
}

Best Practices

Guidelines for effective coroutine usage

Use structured concurrency

Always use coroutineScope or supervisorScope when launching multiple coroutines. This ensures proper cleanup and error propagation.

Check cancellation in long operations

Call this.ensureActive() at critical points in long-running operations to respect cancellation promptly.

Register cleanup callbacks

Use this.onCancel() to ensure resources like connections, timers, and subscriptions are properly cleaned up when a coroutine is cancelled.

Prefer withTimeoutOrNull for optional operations

Use withTimeoutOrNull when the operation is optional and you have a fallback. Use withTimeout when timeout is an error condition.

Next Steps