Effection Logo

The missing structured concurrency guarantees in k6's JavaScript runtime

Taras Mankovski's profile

Taras Mankovski

February 15, 2026

Effection Blog Missing Guarantees in k6's JS Runtime k6 Today group("coolgroup") tags.group=coolgroup c.add(1) delay() future tick tags.group= then: c.add(1) Async deforms the call stack. Next tick runs with different tags. @effectionx/k6 group("coolgroup") tags.group=coolgroup c.add(1) delay() future tick then: c.add(1) Next tick keeps the same tags.

If you've written k6 scripts with async calls, you've probably experienced metrics not getting tagged inside of group() because of async or .then().

This happens because JavaScript treats sync and async differently. What you expect to work with sync group() doesn't work once async gets introduced.

This post is about using structured concurrency to align k6's JavaScript runtime with your expectations.

The diagram at the top shows what goes wrong. Async deforms your call stack. Some code runs on the stack you are in now, and some code runs on a new stack on a future tick. k6 reads the current tags from the sync stack. You expect the tag values in the async callback to be the same, but by the time that callback runs it's too late: group() has already unwound and restored them.

group() is just the tip of the iceberg

group() sets a tag, runs your code, then removes the tag. It finishes immediately - it doesn't wait for async work.

When your .then() callback runs later, group() is already gone. The tag it set? Already removed. Your metrics go to the wrong bucket.

In #2728, @mstoykov describes the same issue:

"As the then callbacks get called only after the stack is empty the whole group code would have been executed, resetting the group back to the root name (which is empty)."

The k6 maintainers explored making group() wait for async work, but decided against it because .then() chains, callback-based APIs like WebSockets, and unclear semantics about what a "group" should wait for created too many corner cases.

That decision makes sense. group() is just the tip of the iceberg: any API that schedules work to run later can lose tags the same way. Fixing them one at a time is whack-a-mole. You fix group(), but the next async API (browser module, gRPC, new timers) has the same drift.

What structured concurrency guarantees

Structured concurrency makes async behave the way you expect sync to behave. It provides two guarantees:

  1. No operation runs longer than its parent.
  2. Every operation exits fully (cleanup runs).

k6's CLI is written in Go, but k6 scripts run inside an embedded JavaScript runtime (Sobek). When you cross async boundaries, k6 has to decide what context applies, how errors surface, and what gets cleaned up on shutdown. Today, k6 mostly uses "whatever happens to be on the call stack"—which is why your expectations don't hold once async gets involved.

The absence of these guarantees explains a category of problems that have accumulated in k6 over years:

Context loss

Grouped metrics drift out of the group that logically owns them.

  • #2728group doesn't work with async calls well
  • #2848 — Change how group() calls async functions

Resource leaks

Open sockets, timers, and background work survive longer than the scenario that created them.

  • #4241 — Goroutine leaks in browser module
  • #785 — Per-VU init lifecycle function (open since 2018)
  • #5382 — VU-level lifecycle hooks

Silent failures

Failures in background async paths get lost or surface too late.

  • #5249 — Unhandled promise rejections don't fail tests
  • #5524 — WebSocket handlers lose async results

Unpredictable shutdown

  • #2804 — Unified shutdown behavior (lists 8 different ways to stop k6, none consistent)
  • #3718 — Graceful interruptions

Race conditions

  • #4203 — Race condition on emitting metrics
  • #5534 — Data race during panic and event loop
  • #3747 — panic: send on closed channel

What it looks like when async matches your expectations

@effectionx/k6 demonstrates what changes when scope owns async work the same way it owns sync work.

Here's the group() problem from #2728:

// BEFORE: group context lost across async
import { Counter } from "k6/metrics";
import { group } from "k6";

const delay = () => Promise.resolve();
const c = new Counter("my_counter");

export default function () {
  group("coolgroup", () => {
    c.add(1); // tagged with group=coolgroup

    delay().then(() => {
      c.add(1); // NOT tagged (runs after group() restored tags)
    });
  });
}
// AFTER: @effectionx/k6 preserves context
import { group, main } from "@effectionx/k6";
import { call } from "effection";
import { Counter } from "k6/metrics";

const delay = () => Promise.resolve();
const c = new Counter("my_counter");

export default main(function* () {
  yield* group("coolgroup", function* () {
    c.add(1); // tagged with group=coolgroup

    // The group scope owns this async work.
    // When the scope exits, child work is canceled.
    yield* call(delay);
    c.add(1); // still tagged
  });
});

The group scope owns the async work the same way it owns sync work. The parent doesn't decide when the child is done, but it does decide when the child is no longer relevant. When the scope exits, cleanup runs.

Effection's design goal: async should just feel normal.

The runtime dependency: ECMAScript conformance and Sobek PR #115

Structured cleanup requires generator.return() to unwind through finally blocks reliably. This behavior is specified in ECMAScript (§27.5.3.4 GeneratorResumeAbrupt): when return() is called on a generator suspended in a try block with a finally, the finally must execute. If the finally contains a yield, the generator suspends there. Subsequent next() calls resume the finally until it completes.

Sobek had a gap here: it was skipping yields in finally blocks during return(), immediately marking the generator as done. This breaks structured cleanup, because cleanup often needs to perform async work (which requires yielding).

Sobek PR #115 fixes this specific behavior. The k6/Sobek project prioritizes ECMAScript conformance, so this isn't a feature request—it's a spec compliance fix that aligns Sobek with V8, SpiderMonkey, and JavaScriptCore.

What the conformance suite tests

The adapter work in effectionx PR #156 includes a conformance suite designed to determine what primitives Sobek already supports and where the gaps are.

What Sobek already supports:

  • Symbols
  • Generators (creation, iteration, yield)
  • Yield delegation (yield*)
  • throw() into generators
  • Promises and microtask scheduling
  • Timers (setTimeout, etc.)
  • AbortController / AbortSignal

What was missing:

  • Async cleanup via generator.return() + finally blocks (fixed by PR #115)

The 05-yield-return.ts tests specifically verify the finally + yield behavior. The k6 adapter tests then build on these primitives to verify:

  • Child work is canceled when parent scope exits
  • Cleanup runs on cancellation paths
  • Errors propagate through owned task trees
  • Shutdown ordering is deterministic under interruption

Most of what Effection needs was already in Sobek. The one missing piece—async cleanup during generator return—is what PR #115 addresses.

Try it

npm install @effectionx/k6 effection

Replace one scenario entrypoint with main(function* () { ... }), wrap one problematic flow in a scoped operation, and run your normal k6 run command.

If child lifetime escapes parent lifetime, file it with a minimal repro. That's the invariant that matters.

If you maintain k6 or Sobek, please review Sobek PR #115 and effectionx PR #156.

When the invariant holds, async behaves the way you expect.