The missing structured concurrency guarantees in k6's JavaScript runtime

Taras Mankovski
February 15, 2026
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
thencallbacks get called only after the stack is empty the wholegroupcode 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:
- No operation runs longer than its parent.
- 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.
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.
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()+finallyblocks (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.