From 3b11f0b5ca92ff9142795d7e2e72fb0f859e0557 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 13:07:03 -0500 Subject: [PATCH 01/27] blog: add k6 structured concurrency case study Document how @effectionx/k6 solves 20+ open k6 issues related to structured concurrency gaps: context loss, resource leaks, silent failures, unpredictable shutdown, and race conditions. References: - Sobek PR #115: https://github.com/grafana/sobek/pull/115 - Effectionx PR #156: https://github.com/thefrontside/effectionx/pull/156 Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- .../index.md | 157 ++++++ .../k6-structured-concurrency.svg | 448 ++++++++++++++++++ 2 files changed, 605 insertions(+) create mode 100644 www/blog/2026-02-15-k6-structured-concurrency/index.md create mode 100644 www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md new file mode 100644 index 000000000..32fae584e --- /dev/null +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -0,0 +1,157 @@ +--- +title: "Structured Concurrency for k6, With Receipts" +description: "k6 has 20+ open issues caused by one missing model. We built @effectionx/k6 to prove structured concurrency solves them." +author: "Taras Mankovski" +tags: ["structured concurrency", "k6", "load testing"] +image: "k6-structured-concurrency.svg" +--- + +The bug report starts the same way. You hit Ctrl-C, and the process still has +work running. Or a request executes after the code that created its context is +already gone. Or the run exits "cleanly" while a failure was swallowed in +background work. + +That is not one bug. That is one missing model. + +Structured concurrency gives us the missing rule: a child cannot outlive its +parent. The parent does not decide when the child is done, but it does decide +when the child is no longer relevant. That distinction is the whole game. + +k6 sits right in the pain because it orchestrates real async work inside a +runtime that historically exposed promises and callbacks without lifetime +ownership. I went looking for the receipts — the actual issue reports — and +found over twenty open issues that trace back to this gap. + +## The pain in five categories + +### 1) Context loss + +The visible symptom is grouped metrics drifting out of the group that logically +owns them. + +- [#2728](https://github.com/grafana/k6/issues/2728) — `group` doesn't work with + async calls well +- [#2848](https://github.com/grafana/k6/issues/2848) — Change how `group()` + calls async functions + +The k6 team decided NOT to support async functions in `group()` because of +corner cases. From #2728: "After even more discussion it was decided to _not_ +support async functions in `group` and `check` at this time." + +### 2) Resource leaks + +Open sockets, timers, and long-lived background work survive longer than the +scenario that created them. + +- [#4241](https://github.com/grafana/k6/issues/4241) — Goroutine leaks in + browser module +- [#785](https://github.com/grafana/k6/issues/785) — Per-VU init lifecycle + function (open since 2018) +- [#5382](https://github.com/grafana/k6/issues/5382) — VU-level lifecycle hooks + +This is classic unowned lifetime. You can start work easily, but there is no +parent scope that must reclaim it on cancellation or exit. + +### 3) Silent failures + +Failures in background async paths get lost or reported too late to be +actionable. + +- [#5249](https://github.com/grafana/k6/issues/5249) — Unhandled promise + rejections don't fail tests +- [#5524](https://github.com/grafana/k6/issues/5524) — WebSocket handlers lose + async results + +When async branches are detached, error propagation becomes accidental. + +### 4) Unpredictable shutdown + +- [#2804](https://github.com/grafana/k6/issues/2804) — Unified shutdown behavior + (lists 8 different ways to stop k6, none consistent) +- [#3718](https://github.com/grafana/k6/issues/3718) — Graceful interruptions + +If shutdown is "best effort" instead of scope-driven, you get races between +in-flight work, teardown, and runtime exit. + +### 5) Race conditions + +- [#4203](https://github.com/grafana/k6/issues/4203) — Race condition on + emitting metrics +- [#5534](https://github.com/grafana/k6/issues/5534) — Data race during panic + and event loop +- [#3747](https://github.com/grafana/k6/issues/3747) — panic: send on closed + channel + +These are what happens when composition exists without lifetime ownership. + +## Before and after + +Here's group context drift — the most common complaint: + +```js +// BEFORE: group context lost across async +group("checkout", async () => { + let res = await http.asyncRequest("GET", url); + check(res, { "status 200": (r) => r.status === 200 }); + // check is NOT tagged with "checkout" — context escaped +}); +``` + +```js +// AFTER: @effectionx/k6 preserves context +import { group, main } from "@effectionx/k6"; +import { call } from "effection"; + +export default main(function* () { + yield* group("checkout", function* () { + let res = yield* call(() => http.asyncRequest("GET", url)); + check(res, { "status 200": (r) => r.status === 200 }); + // check IS tagged — context is scope-owned + }); +}); +``` + +The group owns the lifetime of the work inside it. If the group scope ends, the +child work is canceled with it. + +## The runtime fix + +This work depends on k6's JavaScript runtime (Sobek) honoring generator +cancellation correctly. Specifically, `generator.return()` has to unwind +reliably so `finally` blocks run when scopes are canceled. + +[Sobek PR #115](https://github.com/grafana/sobek/pull/115) closes that +correctness gap. Without it, guarantees become "usually." With it, cleanup and +unwind semantics are dependable enough to build on. + +## Conformance suite: evidence, not vibes + +The adapter work in +[effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156) +includes a conformance suite that asserts the guarantees directly: + +- 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 + +This is the difference between a library API that looks structured and one that +is actually structured. + +## Try it + +```bash +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 you find a case where child lifetime escapes parent lifetime, file it with a +minimal repro. That is the invariant that matters. + +If you maintain k6 or Sobek, please review +[Sobek PR #115](https://github.com/grafana/sobek/pull/115) and +[effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156). + +When the invariant holds, async starts to feel like normal control flow again. diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg new file mode 100644 index 000000000..d0b0e6c08 --- /dev/null +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Effection Blog + Structured Concurrency + for k6, With Receipts + + + + + + + + + + + + + + k6 Today + + + + VU iteration + + + + group() + + + http() + + + + promise + + + + websocket + + + + + + + + Context lost. Resources leak. + + + @effectionx/k6 + + + + main() + + + + group("checkout") + + http() + + + + useWebSocket() + + messages + + + + spawn() + + background work — owned by parent + + + + + + + Context preserved. Cleanup guaranteed. + + From 7e5e1da6080e73dbcc4d860ceb780e64fa0c189e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 13:09:45 -0500 Subject: [PATCH 02/27] style: apply deno fmt to SVG --- .../k6-structured-concurrency.svg | 96 ++++++++++++++++--- 1 file changed, 82 insertions(+), 14 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index d0b0e6c08..4b65caa02 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -387,22 +387,59 @@ k6 Today - + VU iteration - + group() - + http() - + promise - + websocket @@ -411,38 +448,69 @@ - Context lost. Resources leak. + Context lost. Resources leak. - @effectionx/k6 + @effectionx/k6 - + main() - + group("checkout") http() - + useWebSocket() messages - + spawn() - background work — owned by parent + background work — owned by parent - Context preserved. Cleanup guaranteed. - + Context preserved. Cleanup guaranteed. From f3e5f64ca7c089bb5c955a37bdfdbc408e27ecff Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 13:33:51 -0500 Subject: [PATCH 03/27] blog: reframe k6 post around runtime guarantees Replace generic JS hook with k6-specific group/tag drift framing and align examples with k6 maintainer analysis in grafana/k6#2728. Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- .../index.md | 75 +++++++++++++------ .../k6-structured-concurrency.svg | 4 +- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 32fae584e..948e57fad 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -1,26 +1,36 @@ --- -title: "Structured Concurrency for k6, With Receipts" -description: "k6 has 20+ open issues caused by one missing model. We built @effectionx/k6 to prove structured concurrency solves them." +title: "The missing structured concurrency guarantees in k6's JavaScript runtime" +description: "Why groups/tags, errors, and cleanup drift across async boundaries in k6 scripts, and how @effectionx/k6 proves a structured fix." author: "Taras Mankovski" tags: ["structured concurrency", "k6", "load testing"] image: "k6-structured-concurrency.svg" --- -The bug report starts the same way. You hit Ctrl-C, and the process still has -work running. Or a request executes after the code that created its context is -already gone. Or the run exits "cleanly" while a failure was swallowed in -background work. +If you've written non-trivial k6 scripts, you've probably seen some version of +this: the code is "inside" a `group()`, but the metric/check isn't tagged with +that group once an async boundary gets involved. -That is not one bug. That is one missing model. +That's not user error. It's a missing guarantee in the JavaScript runtime. Structured concurrency gives us the missing rule: a child cannot outlive its parent. The parent does not decide when the child is done, but it does decide when the child is no longer relevant. That distinction is the whole game. -k6 sits right in the pain because it orchestrates real async work inside a -runtime that historically exposed promises and callbacks without lifetime -ownership. I went looking for the receipts — the actual issue reports — and -found over twenty open issues that trace back to this gap. +k6's CLI is written in Go, but k6 scripts run inside an embedded JavaScript +runtime (Sobek). When you cross async boundaries there, k6 has to decide what +context (groups/tags) applies, how errors surface, and what gets cleaned up on +shutdown. Today, that model is mostly "whatever happens to be on the call +stack". + +The maintainers have explained this in detail in +[#2728](https://github.com/grafana/k6/issues/2728) and why trying to "just make +`group()` async" quickly becomes inconsistent and surprising +([oleiade's take](https://github.com/grafana/k6/issues/2728#issuecomment-1286933495), +[mstoykov's conclusion](https://github.com/grafana/k6/issues/2728#issuecomment-1404747660)). + +This post is a case study: the category of problems k6 has been running into for +years, and a small package (`@effectionx/k6`) that demonstrates a structured fix +today. ## The pain in five categories @@ -34,9 +44,11 @@ owns them. - [#2848](https://github.com/grafana/k6/issues/2848) — Change how `group()` calls async functions -The k6 team decided NOT to support async functions in `group()` because of -corner cases. From #2728: "After even more discussion it was decided to _not_ -support async functions in `group` and `check` at this time." +The important part isn't whether `group()` accepts an `async function`. It's +that `group()` is implemented like a `try/finally`-scoped tag mutation, so the +tag only applies to the current synchronous call stack. Promise jobs and +callback-based APIs run later, after the `finally` has already restored the old +tags. ### 2) Resource leaks @@ -90,23 +102,40 @@ Here's group context drift — the most common complaint: ```js // BEFORE: group context lost across async -group("checkout", async () => { - let res = await http.asyncRequest("GET", url); - check(res, { "status 200": (r) => r.status === 200 }); - // check is NOT tagged with "checkout" — context escaped -}); +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) + }); + }); +} ``` ```js // 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("checkout", function* () { - let res = yield* call(() => http.asyncRequest("GET", url)); - check(res, { "status 200": (r) => r.status === 200 }); - // check IS tagged — context is scope-owned + yield* group("coolgroup", function* () { + c.add(1); // tagged with group=coolgroup + + // Express the async boundary as part of the structured flow. + // The group owns the lifetime of the work, so context is preserved. + yield* call(delay); + c.add(1); // still tagged }); }); ``` diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index 4b65caa02..020eb452c 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -369,8 +369,8 @@ Effection Blog - Structured Concurrency - for k6, With Receipts + Missing Guarantees + in k6's JS Runtime From 2c7fcc9b2c529bc9cde57e23cad80340a68b42c0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 13:51:12 -0500 Subject: [PATCH 04/27] blog: tighten voice and maintainer references Remove absolution/blame-shift phrasing, make the two guarantees explicit, and keep maintainer discussion to paraphrase plus a single anchored quote. Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- .../index.md | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 948e57fad..28c6e823d 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -10,11 +10,16 @@ If you've written non-trivial k6 scripts, you've probably seen some version of this: the code is "inside" a `group()`, but the metric/check isn't tagged with that group once an async boundary gets involved. -That's not user error. It's a missing guarantee in the JavaScript runtime. +That's a missing guarantee in the JavaScript runtime. Structured concurrency gives us the missing rule: a child cannot outlive its -parent. The parent does not decide when the child is done, but it does decide -when the child is no longer relevant. That distinction is the whole game. +parent. + +Effection's design goal is simple: async should just feel normal. The structured +concurrency part comes down to 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 there, k6 has to decide what @@ -22,17 +27,16 @@ context (groups/tags) applies, how errors surface, and what gets cleaned up on shutdown. Today, that model is mostly "whatever happens to be on the call stack". -The maintainers have explained this in detail in -[#2728](https://github.com/grafana/k6/issues/2728) and why trying to "just make -`group()` async" quickly becomes inconsistent and surprising -([oleiade's take](https://github.com/grafana/k6/issues/2728#issuecomment-1286933495), -[mstoykov's conclusion](https://github.com/grafana/k6/issues/2728#issuecomment-1404747660)). +This is discussed in [#2728](https://github.com/grafana/k6/issues/2728), +including why trying to "just make `group()` async" leads to tricky, +inconsistent semantics +([oleiade](https://github.com/grafana/k6/issues/2728#issuecomment-1286933495), +[mstoykov](https://github.com/grafana/k6/issues/2728#issuecomment-1404747660)). -This post is a case study: the category of problems k6 has been running into for -years, and a small package (`@effectionx/k6`) that demonstrates a structured fix -today. +This post outlines the category of problems k6 has been running into for years, +and a small package (`@effectionx/k6`) that demonstrates a structured fix. -## The pain in five categories +## Five categories of missing guarantees ### 1) Context loss @@ -45,10 +49,22 @@ owns them. calls async functions The important part isn't whether `group()` accepts an `async function`. It's -that `group()` is implemented like a `try/finally`-scoped tag mutation, so the -tag only applies to the current synchronous call stack. Promise jobs and -callback-based APIs run later, after the `finally` has already restored the old -tags. +that `group()` behaves like a `try/finally`-scoped tag mutation: set a tag, +execute a callback, restore the old tag. + +In #2728, @mstoykov describes why `.then()` breaks that illusion: the callback +is scheduled after the current stack unwinds, so the `finally` has already +restored the old group. + +> "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)." — +> [grafana/k6#2728](https://github.com/grafana/k6/issues/2728#issue-1407984526) + +That same thread also captures why an `async/await`-only approach isn't enough +in practice: `.then()` chains and non-promise callback APIs (like the +experimental websocket) still leave you with inconsistent tagging and unclear +definitions of what a "group" should wait for. ### 2) Resource leaks @@ -153,7 +169,7 @@ reliably so `finally` blocks run when scopes are canceled. correctness gap. Without it, guarantees become "usually." With it, cleanup and unwind semantics are dependable enough to build on. -## Conformance suite: evidence, not vibes +## Conformance suite The adapter work in [effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156) @@ -164,8 +180,7 @@ includes a conformance suite that asserts the guarantees directly: - errors propagate through owned task trees - shutdown ordering is deterministic under interruption -This is the difference between a library API that looks structured and one that -is actually structured. +Those are the guarantees the adapter is trying to make testable. ## Try it From 845d33ae3c2c1faf9a956be3f223e2a05958a248 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:00:56 -0500 Subject: [PATCH 05/27] blog: rewrite k6 post with cohesive arc Restructure the post around: 1. The familiar k6 symptom (group/tag drift) 2. Why group() can't fix it (try/finally model + stack unwinding) 3. The two structured concurrency guarantees 4. Five categories of missing guarantees with issue links 5. Before/after example showing scope ownership 6. ECMAScript spec conformance and Sobek PR #115 7. What the conformance suite tests (what Sobek already has vs the one gap) 8. CTA for users and maintainers Voice: paraphrase + one anchored quote per section, neutral attribution, no absolution/blame-shift, explicit two guarantees. Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- .../index.md | 174 +++++++++--------- 1 file changed, 92 insertions(+), 82 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 28c6e823d..52626fdb9 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -1,75 +1,62 @@ --- title: "The missing structured concurrency guarantees in k6's JavaScript runtime" -description: "Why groups/tags, errors, and cleanup drift across async boundaries in k6 scripts, and how @effectionx/k6 proves a structured fix." +description: "Why groups/tags, errors, and cleanup drift across async boundaries in k6 scripts, and how @effectionx/k6 demonstrates a structured fix." author: "Taras Mankovski" tags: ["structured concurrency", "k6", "load testing"] image: "k6-structured-concurrency.svg" --- -If you've written non-trivial k6 scripts, you've probably seen some version of -this: the code is "inside" a `group()`, but the metric/check isn't tagged with -that group once an async boundary gets involved. +If you've written k6 scripts with async calls, you've probably seen this: a +metric is "inside" a `group()`, but it doesn't get tagged with that group once a +`.then()` or promise callback gets involved. That's a missing guarantee in the JavaScript runtime. -Structured concurrency gives us the missing rule: a child cannot outlive its -parent. +## Why `group()` can't fix this on its own -Effection's design goal is simple: async should just feel normal. The structured -concurrency part comes down to two guarantees: +k6's `group()` behaves like a `try/finally`-scoped tag mutation: set a tag, +execute a callback, restore the old tag. In +[#2728](https://github.com/grafana/k6/issues/2728), @mstoykov describes why +`.then()` breaks that model: + +> "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 callback is scheduled after the current stack unwinds, so the `finally` has +already restored the old group. That same thread captures why an +`async/await`-only fix isn't enough: `.then()` chains and callback-based APIs +(like the experimental websocket) still leave you with inconsistent tagging and +unclear definitions of what a "group" should wait for. + +## The missing guarantees + +Structured concurrency 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 there, k6 has to decide what -context (groups/tags) applies, how errors surface, and what gets cleaned up on -shutdown. Today, that model is mostly "whatever happens to be on the call -stack". +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, that +model is mostly "whatever happens to be on the call stack." -This is discussed in [#2728](https://github.com/grafana/k6/issues/2728), -including why trying to "just make `group()` async" leads to tricky, -inconsistent semantics -([oleiade](https://github.com/grafana/k6/issues/2728#issuecomment-1286933495), -[mstoykov](https://github.com/grafana/k6/issues/2728#issuecomment-1404747660)). +The absence of these guarantees explains a category of problems that have +accumulated in k6 over years: -This post outlines the category of problems k6 has been running into for years, -and a small package (`@effectionx/k6`) that demonstrates a structured fix. +### Context loss -## Five categories of missing guarantees - -### 1) Context loss - -The visible symptom is grouped metrics drifting out of the group that logically -owns them. +Grouped metrics drift out of the group that logically owns them. - [#2728](https://github.com/grafana/k6/issues/2728) — `group` doesn't work with async calls well - [#2848](https://github.com/grafana/k6/issues/2848) — Change how `group()` calls async functions -The important part isn't whether `group()` accepts an `async function`. It's -that `group()` behaves like a `try/finally`-scoped tag mutation: set a tag, -execute a callback, restore the old tag. - -In #2728, @mstoykov describes why `.then()` breaks that illusion: the callback -is scheduled after the current stack unwinds, so the `finally` has already -restored the old group. - -> "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)." — -> [grafana/k6#2728](https://github.com/grafana/k6/issues/2728#issue-1407984526) - -That same thread also captures why an `async/await`-only approach isn't enough -in practice: `.then()` chains and non-promise callback APIs (like the -experimental websocket) still leave you with inconsistent tagging and unclear -definitions of what a "group" should wait for. - -### 2) Resource leaks +### Resource leaks -Open sockets, timers, and long-lived background work survive longer than the -scenario that created them. +Open sockets, timers, and background work survive longer than the scenario that +created them. - [#4241](https://github.com/grafana/k6/issues/4241) — Goroutine leaks in browser module @@ -77,31 +64,22 @@ scenario that created them. function (open since 2018) - [#5382](https://github.com/grafana/k6/issues/5382) — VU-level lifecycle hooks -This is classic unowned lifetime. You can start work easily, but there is no -parent scope that must reclaim it on cancellation or exit. - -### 3) Silent failures +### Silent failures -Failures in background async paths get lost or reported too late to be -actionable. +Failures in background async paths get lost or surface too late. - [#5249](https://github.com/grafana/k6/issues/5249) — Unhandled promise rejections don't fail tests - [#5524](https://github.com/grafana/k6/issues/5524) — WebSocket handlers lose async results -When async branches are detached, error propagation becomes accidental. - -### 4) Unpredictable shutdown +### Unpredictable shutdown - [#2804](https://github.com/grafana/k6/issues/2804) — Unified shutdown behavior (lists 8 different ways to stop k6, none consistent) - [#3718](https://github.com/grafana/k6/issues/3718) — Graceful interruptions -If shutdown is "best effort" instead of scope-driven, you get races between -in-flight work, teardown, and runtime exit. - -### 5) Race conditions +### Race conditions - [#4203](https://github.com/grafana/k6/issues/4203) — Race condition on emitting metrics @@ -110,11 +88,11 @@ in-flight work, teardown, and runtime exit. - [#3747](https://github.com/grafana/k6/issues/3747) — panic: send on closed channel -These are what happens when composition exists without lifetime ownership. +## What structured ownership looks like -## Before and after +`@effectionx/k6` demonstrates what changes when scope owns async work. -Here's group context drift — the most common complaint: +Here's the `group()` problem from #2728: ```js // BEFORE: group context lost across async @@ -148,39 +126,71 @@ export default main(function* () { yield* group("coolgroup", function* () { c.add(1); // tagged with group=coolgroup - // Express the async boundary as part of the structured flow. - // The group owns the lifetime of the work, so context is preserved. + // 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 owns the lifetime of the work inside it. If the group scope ends, the -child work is canceled with it. +The group scope owns the async 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 is simple: async should just feel normal. -## The runtime fix +## The runtime dependency: ECMAScript conformance and Sobek PR #115 -This work depends on k6's JavaScript runtime (Sobek) honoring generator -cancellation correctly. Specifically, `generator.return()` has to unwind -reliably so `finally` blocks run when scopes are canceled. +Structured cleanup requires `generator.return()` to unwind through `finally` +blocks reliably. This behavior is specified in ECMAScript +([§27.5.3.4 GeneratorResumeAbrupt](https://tc39.es/ecma262/#sec-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 PR #115](https://github.com/grafana/sobek/pull/115) closes that -correctness gap. Without it, guarantees become "usually." With it, cleanup and -unwind semantics are dependable enough to build on. +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). -## Conformance suite +[Sobek PR #115](https://github.com/grafana/sobek/pull/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](https://github.com/thefrontside/effectionx/pull/156) -includes a conformance suite that asserts the guarantees directly: +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 +- 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 -Those are the guarantees the adapter is trying to make testable. +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 @@ -191,8 +201,8 @@ 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 you find a case where child lifetime escapes parent lifetime, file it with a -minimal repro. That is the invariant that matters. +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](https://github.com/grafana/sobek/pull/115) and From 6f661d71c000a99b5b1bc6479e164192756469a1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:06:58 -0500 Subject: [PATCH 06/27] blog: align voice with expectations framing Reframe opening and key transitions around 'your expectations': - sync group() works the way you expect; async breaks that - structured concurrency aligns async with those expectations - scope owns async the same way it owns sync Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- .../index.md | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 52626fdb9..0fe2419c7 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -6,11 +6,14 @@ tags: ["structured concurrency", "k6", "load testing"] image: "k6-structured-concurrency.svg" --- -If you've written k6 scripts with async calls, you've probably seen this: a -metric is "inside" a `group()`, but it doesn't get tagged with that group once a -`.then()` or promise callback gets involved. +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()`. -That's a missing guarantee in the JavaScript runtime. +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. ## Why `group()` can't fix this on its own @@ -29,9 +32,10 @@ already restored the old group. That same thread captures why an (like the experimental websocket) still leave you with inconsistent tagging and unclear definitions of what a "group" should wait for. -## The missing guarantees +## What structured concurrency guarantees -Structured concurrency provides two 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). @@ -39,7 +43,8 @@ Structured concurrency provides two guarantees: 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, that -model is mostly "whatever happens to be on the call stack." +model is mostly "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: @@ -88,9 +93,10 @@ Failures in background async paths get lost or surface too late. - [#3747](https://github.com/grafana/k6/issues/3747) — panic: send on closed channel -## What structured ownership looks like +## What it looks like when async matches your expectations -`@effectionx/k6` demonstrates what changes when scope owns async work. +`@effectionx/k6` demonstrates what changes when scope owns async work the same +way it owns sync work. Here's the `group()` problem from #2728: @@ -134,11 +140,11 @@ export default main(function* () { }); ``` -The group scope owns the async 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. +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 is simple: async should just feel normal. +Effection's design goal: async should just feel normal. ## The runtime dependency: ECMAScript conformance and Sobek PR #115 @@ -208,4 +214,4 @@ If you maintain k6 or Sobek, please review [Sobek PR #115](https://github.com/grafana/sobek/pull/115) and [effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156). -When the invariant holds, async starts to feel like normal control flow again. +When the invariant holds, async behaves the way you expect. From 9e31cf5168aba341f8ad64f2f713ff723e5c869f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:26:06 -0500 Subject: [PATCH 07/27] blog: illustrate stack deformation across ticks Update featured image to show async deforming the call stack: a then() callback runs on the next tick with restored tags, so group attribution changes. Add a short bridge paragraph tying the diagram to k6 tag behavior. Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- .../index.md | 5 + .../k6-structured-concurrency.svg | 180 +++++++++--------- 2 files changed, 99 insertions(+), 86 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 0fe2419c7..2f16c80e7 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -15,6 +15,11 @@ 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 doesn't just add time, it +deforms your call stack. Some code runs on the stack you are in now, and some +code runs on a new stack on the next tick. When that happens, the "current" tags +are different, because `group()` has already unwound and restored them. + ## Why `group()` can't fix this on its own k6's `group()` behaves like a `try/finally`-scoped tag mutation: set a tag, diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index 020eb452c..48d4ace91 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -8,7 +8,7 @@ fill="none" xmlns="http://www.w3.org/2000/svg" role="img" - aria-label="Split view showing tangled async wires escaping scope on left, clean nested scopes on right" + aria-label="Diagram showing async shifting work to the next tick where tags differ, and how structured scopes keep tags consistent" > @@ -138,6 +138,12 @@ "Liberation Mono", "Courier New", monospace; font-size: 13px; } + .svg-mono-sm { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 12px; + } /* ---- Light mode (default) ---- */ .svg-bg-light { @@ -234,6 +240,12 @@ .svg-mono { fill: #1e3a8a; } + .svg-mono-sm { + fill: #1e3a8a; + } + .svg-warn-text { + fill: #ea580c; + } /* ---- Dark mode ---- */ @media (prefers-color-scheme: dark) { @@ -316,6 +328,12 @@ .svg-mono { fill: #e5e7eb; } + .svg-mono-sm { + fill: #e5e7eb; + } + .svg-warn-text { + fill: #fb923c; + } } @@ -383,134 +401,124 @@ - + k6 Today - + - VU iteration - - - - group() + group("coolgroup") + tags.group=coolgroup + - http() + + c.add(1) - - promise - + + delay() + - websocket - + next tick + tags.group= + + then: c.add(1) - - - + + - - Context lost. Resources leak. + + Async deforms the call stack. + Next tick runs with different tags. - + @effectionx/k6 - + - main() + group("coolgroup") + tags.group=coolgroup - + - group("checkout") - - http() + + c.add(1) - - useWebSocket() - - messages + + delay() - + - spawn() - - background work — owned by parent + next tick + + then: c.add(1) - - - + + - Context preserved. Cleanup guaranteed. + Next tick keeps the same tags. From 3eef9c51b23199ca0c87e801130a554738a3cc07 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:27:02 -0500 Subject: [PATCH 08/27] blog: rename next tick to future tick Use "future tick" language consistently in the diagram and bridge paragraph. Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- www/blog/2026-02-15-k6-structured-concurrency/index.md | 2 +- .../k6-structured-concurrency.svg | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 2f16c80e7..cc7659f43 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -17,7 +17,7 @@ with your expectations. The diagram at the top shows what goes wrong. Async doesn't just add time, it deforms your call stack. Some code runs on the stack you are in now, and some -code runs on a new stack on the next tick. When that happens, the "current" tags +code runs on a new stack on a future tick. When that happens, the "current" tags are different, because `group()` has already unwound and restored them. ## Why `group()` can't fix this on its own diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index 48d4ace91..99f26b6cb 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -8,7 +8,7 @@ fill="none" xmlns="http://www.w3.org/2000/svg" role="img" - aria-label="Diagram showing async shifting work to the next tick where tags differ, and how structured scopes keep tags consistent" + aria-label="Diagram showing async shifting work to a future tick where tags differ, and how structured scopes keep tags consistent" > @@ -449,12 +449,12 @@ rx="8" style="stroke: #ea580c; stroke-width: 2" /> - next tick + future tick tags.group= then: c.add(1) - + @@ -511,7 +511,7 @@ height="50" rx="8" /> - next tick + future tick then: c.add(1) From 033ca80ad5f2ecc49e6d86ff2702a0f3282b4698 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:27:36 -0500 Subject: [PATCH 09/27] blog: tighten stack deformation sentence Session-ID: ses_39d99b9c2ffeSKxHPZAk9P7E1O --- www/blog/2026-02-15-k6-structured-concurrency/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index cc7659f43..97a251d18 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -15,10 +15,10 @@ 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 doesn't just add time, it -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. When that happens, the "current" tags -are different, because `group()` has already unwound and restored them. +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. When that happens, the "current" tags are different, because +`group()` has already unwound and restored them. ## Why `group()` can't fix this on its own From d19954746283f8af7f5c00ac078cb7ba388bc3fb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:30:14 -0500 Subject: [PATCH 10/27] blog: clarify tags differ across stacks Explain that k6 reads current tags from the sync stack, but async callbacks run on a future tick after group() has unwound and restored tags. Session-ID: ses_39d99bc2ffeSKxHPZAk9P7E1O --- www/blog/2026-02-15-k6-structured-concurrency/index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 97a251d18..fa4560c1c 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -17,8 +17,9 @@ 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. When that happens, the "current" tags are different, because -`group()` has already unwound and restored them. +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. ## Why `group()` can't fix this on its own From 5049df1192ffdf985415ef5032b8f6cbdc5f3b17 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:41:07 -0500 Subject: [PATCH 11/27] blog: clarify why group() can't cover async Session-ID: w0t1p0:529AE7AB-BFCA-4B51-9880-FDF1D759DFBE --- .../index.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index fa4560c1c..62cb9def2 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -23,20 +23,28 @@ it's too late: `group()` has already unwound and restored them. ## Why `group()` can't fix this on its own -k6's `group()` behaves like a `try/finally`-scoped tag mutation: set a tag, -execute a callback, restore the old tag. In -[#2728](https://github.com/grafana/k6/issues/2728), @mstoykov describes why -`.then()` breaks that model: +`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](https://github.com/grafana/k6/issues/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 callback is scheduled after the current stack unwinds, so the `finally` has -already restored the old group. That same thread captures why an -`async/await`-only fix isn't enough: `.then()` chains and callback-based APIs -(like the experimental websocket) still leave you with inconsistent tagging and -unclear definitions of what a "group" should wait for. +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. The problem isn't `group()` - it's the model. +Synchronizing async and sync stacks one API 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 From 68480da772efd681743fe015abfd8f7e98e66d3e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 14:46:14 -0500 Subject: [PATCH 12/27] blog: reframe group() as iceberg Session-ID: w0t1p0:529AE7AB-BFCA-4B51-9880-FDF1D759DFBE --- .../2026-02-15-k6-structured-concurrency/index.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 62cb9def2..c18fa4dfe 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -21,7 +21,7 @@ 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. -## Why `group()` can't fix this on its own +## `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. @@ -41,10 +41,10 @@ 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. The problem isn't `group()` - it's the model. -Synchronizing async and sync stacks one API at a time is whack-a-mole. You fix -`group()`, but the next async API (browser module, gRPC, new timers) has the -same drift. +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 @@ -56,8 +56,8 @@ provides two guarantees: 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, that -model is mostly "whatever happens to be on the call stack"—which is why your +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 From 0857b4de53e6b941752379c7199954affd8580ac Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 15:14:08 -0500 Subject: [PATCH 13/27] blog: rewrite k6 post with tighter structure - Cut 45-line GitHub issue list - Add bridge paragraph (universal async problem) - Combine technical sections - Tighten to ~650 words --- .../index.md | 214 ++++++------------ 1 file changed, 63 insertions(+), 151 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index c18fa4dfe..800d19e6e 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -6,116 +6,63 @@ tags: ["structured concurrency", "k6", "load testing"] image: "k6-structured-concurrency.svg" --- -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()`. +You have probably seen this one already: a metric increment that should be under +`group()` shows up untagged. The script looks correct. The output does not. +Nothing is obviously broken, but your context drifted across an async boundary +and your data is now lying to you. -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. +This post explains why that happens, why it is bigger than `group()`, and what +it looks like when the runtime gives you the missing lifetime guarantees. ## `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. +At a high level, `group()` does three synchronous things: set the current group +tag, run your callback, restore the previous tag. That works for synchronous +code because the callback finishes before control returns. -When your `.then()` callback runs later, `group()` is already gone. The tag it -set? Already removed. Your metrics go to the wrong bucket. +Promises do not work that way. If you schedule `.then()`, that callback runs +later, after the current stack is empty. By then, `group()` has already restored +tags. -In [#2728](https://github.com/grafana/k6/issues/2728), @mstoykov describes the -same issue: +As @mstoykov put it in [#2728](https://github.com/grafana/k6/issues/2728): > "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. +Maintainers explored making `group()` "wait" for async work. It sounds simple +until you hit the corner cases: which promises count, how far transitive waiting +goes, what to do with detached callbacks, what to do with timers, what to do +with user abstractions built on top of all of that. You patch one path, another +leaks. -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. +This is not a k6-specific bug; it is what unstructured async does. Once work can +be scheduled to run later—callbacks, promises, futures—it can outlive the task +that started it, and context like tags drifts. Other ecosystems hit the same +wall: Python added `TaskGroup` in 3.11, and Kotlin, Swift, and Java now ship +structured concurrency with parent-child lifetime guarantees. -## What structured concurrency guarantees +## The common solution: structured concurrency -Structured concurrency makes async behave the way you expect sync to behave. It -provides two guarantees: +Structured concurrency gives 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. - -- [#2728](https://github.com/grafana/k6/issues/2728) — `group` doesn't work with - async calls well -- [#2848](https://github.com/grafana/k6/issues/2848) — Change how `group()` - calls async functions - -### Resource leaks - -Open sockets, timers, and background work survive longer than the scenario that -created them. - -- [#4241](https://github.com/grafana/k6/issues/4241) — Goroutine leaks in - browser module -- [#785](https://github.com/grafana/k6/issues/785) — Per-VU init lifecycle - function (open since 2018) -- [#5382](https://github.com/grafana/k6/issues/5382) — VU-level lifecycle hooks - -### Silent failures - -Failures in background async paths get lost or surface too late. - -- [#5249](https://github.com/grafana/k6/issues/5249) — Unhandled promise - rejections don't fail tests -- [#5524](https://github.com/grafana/k6/issues/5524) — WebSocket handlers lose - async results - -### Unpredictable shutdown +2. Every operation exits fully. -- [#2804](https://github.com/grafana/k6/issues/2804) — Unified shutdown behavior - (lists 8 different ways to stop k6, none consistent) -- [#3718](https://github.com/grafana/k6/issues/3718) — Graceful interruptions +Those two constraints sound strict because they are strict. They are also +exactly what keeps context, errors, and cleanup coherent. -### Race conditions +k6 scripts run in Sobek, an embedded JavaScript runtime. If Sobek enforces +structured lifetime semantics, k6 gets the same guarantees at script level: work +cannot outlive scope, and scope exit means real exit, including cleanup. The +long tail of async drift problems (tags, teardown, propagated failures, +cancellation behavior) collapses into one model instead of many local fixes. -- [#4203](https://github.com/grafana/k6/issues/4203) — Race condition on - emitting metrics -- [#5534](https://github.com/grafana/k6/issues/5534) — Data race during panic - and event loop -- [#3747](https://github.com/grafana/k6/issues/3747) — panic: send on closed - channel +## What it looks like -## 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: +Here is the drift in plain code: ```js -// BEFORE: group context lost across async import { Counter } from "k6/metrics"; import { group } from "k6"; @@ -133,8 +80,9 @@ export default function () { } ``` +And here is the same scenario with `@effectionx/k6`: + ```js -// AFTER: @effectionx/k6 preserves context import { group, main } from "@effectionx/k6"; import { call } from "effection"; import { Counter } from "k6/metrics"; @@ -146,86 +94,50 @@ 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](https://tc39.es/ecma262/#sec-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). +The group scope owns the async work the same way it owns sync work. Lifetime is +structural, not incidental. Work started in scope stays in scope. -[Sobek PR #115](https://github.com/grafana/sobek/pull/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. +Async should just feel normal. -## What the conformance suite tests +## What k6 needs: Sobek PR #115 -The adapter work in -[effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156) -includes a conformance suite designed to determine what primitives Sobek already -supports and where the gaps are. +To make this correct, cleanup must run during structured cancellation and +unwind. In JavaScript terms, `generator.return()` must execute `finally` blocks +correctly. -**What Sobek already supports:** +Sobek had a gap here: during `return`, yields inside `finally` were skipped. +That breaks structured cleanup because "exit fully" stops being true at exactly +the point where cleanup needs to happen. -- Symbols -- Generators (creation, iteration, `yield`) -- Yield delegation (`yield*`) -- `throw()` into generators -- Promises and microtask scheduling -- Timers (`setTimeout`, etc.) -- `AbortController` / `AbortSignal` +[Sobek PR #115](https://github.com/grafana/sobek/pull/115) fixes that behavior. +This is ECMAScript conformance work, not a feature request. The runtime is +aligning with the language contract. -**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. +There is also [effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156), +which includes a conformance suite around these semantics so behavior stays +locked as integration evolves. ## Try it +Install the package: + ```bash 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. +Take one existing script that uses `group()` with any promise boundary, convert +`export default function () {}` to `export default main(function* () {})`, then +move that path under `yield* group(...)` and replace promise bridging with +`yield* call(...)`. -If you maintain k6 or Sobek, please review -[Sobek PR #115](https://github.com/grafana/sobek/pull/115) and -[effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156). +If you maintain k6 or Sobek, please review the PRs and the conformance cases. +The runtime boundary is where this guarantee has to hold, or it will leak +everywhere above it. -When the invariant holds, async behaves the way you expect. +When the invariant holds, async stops lying. From ec9ec46da1db9aca18f26149b49b9cc6d080f9f7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 15:27:05 -0500 Subject: [PATCH 14/27] blog: use real k6 APIs, clarify universal solution - Replace fake delay() with http.asyncRequest() - Use until() instead of call() for promises - Rename section to 'Universal solution' - Clarify guarantees match sync expectations --- .../index.md | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 800d19e6e..c592e26fc 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -38,19 +38,20 @@ leaks. This is not a k6-specific bug; it is what unstructured async does. Once work can be scheduled to run later—callbacks, promises, futures—it can outlive the task -that started it, and context like tags drifts. Other ecosystems hit the same +that started it, and state like tags can drift. Other ecosystems hit the same wall: Python added `TaskGroup` in 3.11, and Kotlin, Swift, and Java now ship structured concurrency with parent-child lifetime guarantees. -## The common solution: structured concurrency +## Universal solution: structured concurrency -Structured concurrency gives two guarantees: +Structured concurrency binds all sync and async operations to the same stack and +provides two guarantees that eliminate large categories of async problems: 1. No operation runs longer than its parent. -2. Every operation exits fully. +2. Every operation runs its cleanup. -Those two constraints sound strict because they are strict. They are also -exactly what keeps context, errors, and cleanup coherent. +These are the same constraints we expect from sync code, applied consistently to +both sync and async. k6 scripts run in Sobek, an embedded JavaScript runtime. If Sobek enforces structured lifetime semantics, k6 gets the same guarantees at script level: work @@ -63,17 +64,17 @@ cancellation behavior) collapses into one model instead of many local fixes. Here is the drift in plain code: ```js -import { Counter } from "k6/metrics"; import { group } from "k6"; +import http from "k6/http"; +import { Counter } from "k6/metrics"; -const delay = () => Promise.resolve(); const c = new Counter("my_counter"); export default function () { group("coolgroup", () => { c.add(1); // tagged with group=coolgroup - delay().then(() => { + http.asyncRequest("GET", "https://test.k6.io").then(() => { c.add(1); // NOT tagged (runs after group() restored tags) }); }); @@ -84,17 +85,17 @@ And here is the same scenario with `@effectionx/k6`: ```js import { group, main } from "@effectionx/k6"; -import { call } from "effection"; +import { until } from "effection"; +import http from "k6/http"; 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 - yield* call(delay); + yield* until(http.asyncRequest("GET", "https://test.k6.io")); c.add(1); // still tagged }); }); @@ -134,7 +135,7 @@ npm install @effectionx/k6 effection Take one existing script that uses `group()` with any promise boundary, convert `export default function () {}` to `export default main(function* () {})`, then move that path under `yield* group(...)` and replace promise bridging with -`yield* call(...)`. +`yield* until(...)`. If you maintain k6 or Sobek, please review the PRs and the conformance cases. The runtime boundary is where this guarantee has to hold, or it will leak From 6482c21bd0c7593fbd09ddf760348bc1ca689d76 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 15:31:09 -0500 Subject: [PATCH 15/27] blog: restore original introduction --- .../index.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index c592e26fc..ace5b1e34 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -6,13 +6,20 @@ tags: ["structured concurrency", "k6", "load testing"] image: "k6-structured-concurrency.svg" --- -You have probably seen this one already: a metric increment that should be under -`group()` shows up untagged. The script looks correct. The output does not. -Nothing is obviously broken, but your context drifted across an async boundary -and your data is now lying to you. +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 post explains why that happens, why it is bigger than `group()`, and what -it looks like when the runtime gives you the missing lifetime guarantees. +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 From 97d7cbdfd1ca89121efb4d866baefbf81633a8fd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 15:51:12 -0500 Subject: [PATCH 16/27] blog: add Why Effection section, improve flow - Add transition paragraph before code examples - Add 'Why Effection for k6?' section explaining polyfill design - Include Rosetta Stone link - Frame as easy/safe choice until JS runtime adds guarantees - Note additive adoption without Sobek changes beyond ECMAScript --- .../index.md | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index ace5b1e34..16a2b3fa2 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -60,11 +60,9 @@ provides two guarantees that eliminate large categories of async problems: These are the same constraints we expect from sync code, applied consistently to both sync and async. -k6 scripts run in Sobek, an embedded JavaScript runtime. If Sobek enforces -structured lifetime semantics, k6 gets the same guarantees at script level: work -cannot outlive scope, and scope exit means real exit, including cleanup. The -long tail of async drift problems (tags, teardown, propagated failures, -cancellation behavior) collapses into one model instead of many local fixes. +The rest of this post shows what it looks like when these problems are fixed +with structured concurrency using Effection, and what's missing from Sobek to +make it work in k6. ## What it looks like @@ -113,13 +111,32 @@ structural, not incidental. Work started in scope stays in scope. Async should just feel normal. +## Why Effection for k6? + +Effection is a structured concurrency library for JavaScript, designed as a +polyfill until the language adopts these semantics natively. It's tiny (<5k +gzipped), mature (used in production since 2019), and easy to drop in and +experiment with. If you know `async/await`, the translation is mostly mechanical: +`async function` becomes `function*`, `await` becomes `yield*`. The Effection +docs include a [Rosetta Stone](https://frontside.com/effection/docs/rosetta-stone) +that maps common async patterns to their structured equivalents. + +Every framework that handles concurrent work eventually faces this choice: +keep patching async edge cases one by one, or adopt a model that eliminates the +category of problems. Kotlin, Swift, Python, and Java all chose structured +concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. +Effection's goal is to make this choice easy and safe until these guarantees are +added to the JavaScript runtime. Its low learning curve and small footprint make +it a good candidate for k6 scripts. It can be adopted incrementally — one script +at a time — without requiring any changes to Sobek beyond ECMAScript compliance. + ## What k6 needs: Sobek PR #115 -To make this correct, cleanup must run during structured cancellation and -unwind. In JavaScript terms, `generator.return()` must execute `finally` blocks -correctly. +To make structured cleanup work, `generator.return()` must execute `finally` +blocks correctly. This is specified in ECMAScript — when `return()` is called on +a generator suspended in a `try` block with a `finally`, the `finally` must run. -Sobek had a gap here: during `return`, yields inside `finally` were skipped. +Sobek had a gap here: during `return()`, yields inside `finally` were skipped. That breaks structured cleanup because "exit fully" stops being true at exactly the point where cleanup needs to happen. @@ -127,21 +144,18 @@ the point where cleanup needs to happen. This is ECMAScript conformance work, not a feature request. The runtime is aligning with the language contract. -There is also [effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156), -which includes a conformance suite around these semantics so behavior stays -locked as integration evolves. +[effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156) +includes a conformance suite that locks these semantics as integration evolves. ## Try it -Install the package: - ```bash npm install @effectionx/k6 effection ``` -Take one existing script that uses `group()` with any promise boundary, convert -`export default function () {}` to `export default main(function* () {})`, then -move that path under `yield* group(...)` and replace promise bridging with +Take one existing script that uses `group()` with any promise boundary. Replace +`export default function () {}` with `export default main(function* () {})`, +wrap the async path in `yield* group(...)`, and replace `.then()` chains with `yield* until(...)`. If you maintain k6 or Sobek, please review the PRs and the conformance cases. From 9487a6abc2c0a5fed0be4531c5c887756d4125fc Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 15:57:11 -0500 Subject: [PATCH 17/27] blog: add preamble with test suite link before code example --- www/blog/2026-02-15-k6-structured-concurrency/index.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 16a2b3fa2..2c211a512 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -66,7 +66,14 @@ make it work in k6. ## What it looks like -Here is the drift in plain code: +There are many ways async can drift outside its scope — `.then()` callbacks, +`setTimeout`, WebSocket handlers, and more. The +[`@effectionx/k6` test suite](https://github.com/thefrontside/effectionx/tree/feat/effectionx-k6-preview/k6/tests) +covers these cases. Here's one common example. + +Both `c.add(1)` calls are inside `group("coolgroup", ...)`, so you'd expect both +to be tagged. But the second one runs in a `.then()` callback — by then, +`group()` has already finished and removed the tag: ```js import { group } from "k6"; From fe3d9a663ea23bafeadf9f67c087ed04a8b66524 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:07:48 -0500 Subject: [PATCH 18/27] blog: improve code example context, add conformance suite story - Replace abstract 'Lifetime is structural' with concrete code comparison - Rename Sobek section to 'Missing ECMAScript compliance' - Add link to conformance suite gist showing 80% compatibility - Link to ECMAScript spec for Generator.prototype.return - Show that only async cleanup was missing --- .../index.md | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 2c211a512..6a8578b70 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -113,10 +113,10 @@ export default main(function* () { }); ``` -The group scope owns the async work the same way it owns sync work. Lifetime is -structural, not incidental. Work started in scope stays in scope. - -Async should just feel normal. +The code looks almost identical — `function*` instead of `function`, `yield*` +instead of `.then()`. But now it works the way you'd expect: both counter +increments are tagged, because the group scope owns the async work the same way +it owns sync work. ## Why Effection for k6? @@ -137,19 +137,30 @@ added to the JavaScript runtime. Its low learning curve and small footprint make it a good candidate for k6 scripts. It can be adopted incrementally — one script at a time — without requiring any changes to Sobek beyond ECMAScript compliance. -## What k6 needs: Sobek PR #115 +## Missing ECMAScript compliance to support structured concurrency + +Before building `@effectionx/k6`, we ran a +[conformance suite](https://gist.github.com/taras/ba692690e1695c44dedcc71a6624880b) +to verify which JavaScript primitives Sobek supports. The results: Sobek already +handles most of what Effection needs — symbols, generators, `yield*` delegation, +error forwarding, promises, timers, and `AbortController`. + +One piece was missing: async cleanup. -To make structured cleanup work, `generator.return()` must execute `finally` -blocks correctly. This is specified in ECMAScript — when `return()` is called on -a generator suspended in a `try` block with a `finally`, the `finally` must run. +When `return()` is called on a generator suspended in a `try` block with a +`finally`, ECMAScript requires the `finally` to execute. If that `finally` +contains a `yield`, the generator should suspend there and resume on the next +`next()` call. Sobek was skipping those yields, immediately marking the +generator as done. -Sobek had a gap here: during `return()`, yields inside `finally` were skipped. -That breaks structured cleanup because "exit fully" stops being true at exactly -the point where cleanup needs to happen. +This breaks structured cleanup — "exit fully" stops being true at exactly the +point where cleanup needs to happen. -[Sobek PR #115](https://github.com/grafana/sobek/pull/115) fixes that behavior. -This is ECMAScript conformance work, not a feature request. The runtime is -aligning with the language contract. +[Sobek PR #115](https://github.com/grafana/sobek/pull/115) fixes +`generator.return()` to honor +[ECMAScript's Generator.prototype.return](https://tc39.es/ecma262/#sec-generator.prototype.return): +`finally` blocks must execute even when they contain `yield`. Without that fix, +Effection's cleanup guarantees fail on cancellation paths. [effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156) includes a conformance suite that locks these semantics as integration evolves. From 916489ede643dbd8d5a8505051532e915f0398ad Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:09:13 -0500 Subject: [PATCH 19/27] blog: move 'Why Effection for k6' after ECMAScript compliance section --- .../index.md | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 6a8578b70..61d6792ea 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -118,25 +118,6 @@ instead of `.then()`. But now it works the way you'd expect: both counter increments are tagged, because the group scope owns the async work the same way it owns sync work. -## Why Effection for k6? - -Effection is a structured concurrency library for JavaScript, designed as a -polyfill until the language adopts these semantics natively. It's tiny (<5k -gzipped), mature (used in production since 2019), and easy to drop in and -experiment with. If you know `async/await`, the translation is mostly mechanical: -`async function` becomes `function*`, `await` becomes `yield*`. The Effection -docs include a [Rosetta Stone](https://frontside.com/effection/docs/rosetta-stone) -that maps common async patterns to their structured equivalents. - -Every framework that handles concurrent work eventually faces this choice: -keep patching async edge cases one by one, or adopt a model that eliminates the -category of problems. Kotlin, Swift, Python, and Java all chose structured -concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. -Effection's goal is to make this choice easy and safe until these guarantees are -added to the JavaScript runtime. Its low learning curve and small footprint make -it a good candidate for k6 scripts. It can be adopted incrementally — one script -at a time — without requiring any changes to Sobek beyond ECMAScript compliance. - ## Missing ECMAScript compliance to support structured concurrency Before building `@effectionx/k6`, we ran a @@ -165,6 +146,25 @@ Effection's cleanup guarantees fail on cancellation paths. [effectionx PR #156](https://github.com/thefrontside/effectionx/pull/156) includes a conformance suite that locks these semantics as integration evolves. +## Why Effection for k6? + +Effection is a structured concurrency library for JavaScript, designed as a +polyfill until the language adopts these semantics natively. It's tiny (<5k +gzipped), mature (used in production since 2019), and easy to drop in and +experiment with. If you know `async/await`, the translation is mostly mechanical: +`async function` becomes `function*`, `await` becomes `yield*`. The Effection +docs include a [Rosetta Stone](https://frontside.com/effection/docs/rosetta-stone) +that maps common async patterns to their structured equivalents. + +Every framework that handles concurrent work eventually faces this choice: +keep patching async edge cases one by one, or adopt a model that eliminates the +category of problems. Kotlin, Swift, Python, and Java all chose structured +concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. +Effection's goal is to make this choice easy and safe until these guarantees are +added to the JavaScript runtime. Its low learning curve and small footprint make +it a good candidate for k6 scripts. It can be adopted incrementally — one script +at a time — without requiring any changes to Sobek beyond ECMAScript compliance. + ## Try it ```bash From fa9754e744f527d0a364759d056fea28a326c088 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:10:23 -0500 Subject: [PATCH 20/27] blog: reorder 'Why Effection' paragraphs - context first, then solution --- www/blog/2026-02-15-k6-structured-concurrency/index.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 61d6792ea..d587ee7f3 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -148,6 +148,11 @@ includes a conformance suite that locks these semantics as integration evolves. ## Why Effection for k6? +Every framework that handles concurrent work eventually faces this choice: +keep patching async edge cases one by one, or adopt a model that eliminates the +category of problems. Kotlin, Swift, Python, and Java all chose structured +concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. + Effection is a structured concurrency library for JavaScript, designed as a polyfill until the language adopts these semantics natively. It's tiny (<5k gzipped), mature (used in production since 2019), and easy to drop in and @@ -156,10 +161,6 @@ experiment with. If you know `async/await`, the translation is mostly mechanical docs include a [Rosetta Stone](https://frontside.com/effection/docs/rosetta-stone) that maps common async patterns to their structured equivalents. -Every framework that handles concurrent work eventually faces this choice: -keep patching async edge cases one by one, or adopt a model that eliminates the -category of problems. Kotlin, Swift, Python, and Java all chose structured -concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. Effection's goal is to make this choice easy and safe until these guarantees are added to the JavaScript runtime. Its low learning curve and small footprint make it a good candidate for k6 scripts. It can be adopted incrementally — one script From 88f08dd1c9a3a2714e3e864a4d9bf7126ba75355 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:32:14 -0500 Subject: [PATCH 21/27] blog: fix Try It section and redesign thumbnail SVG - Replace incorrect npm install instructions with Docker-based setup - Redesign SVG to show vertical execution sequence with animated tag state - Left side shows async/await losing tags before .then() runs - Right side shows structured concurrency preserving tags through async - Animation highlights when tag state changes (the key insight) --- .../index.md | 20 +- .../k6-structured-concurrency.svg | 761 ++++++++---------- 2 files changed, 346 insertions(+), 435 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index d587ee7f3..65ffdcff2 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -168,14 +168,24 @@ at a time — without requiring any changes to Sobek beyond ECMAScript complianc ## Try it +`@effectionx/k6` requires a custom k6 binary built with Sobek PR #115. The +easiest way to try it is with Docker: + +```bash +git clone https://github.com/thefrontside/effectionx.git +cd effectionx/k6 +docker compose run --rm k6-test +``` + +This builds k6 with the Sobek fix and runs the test suite. To run the demos: + ```bash -npm install @effectionx/k6 effection +docker compose run --rm k6-demo 01-group-context.js ``` -Take one existing script that uses `group()` with any promise boundary. Replace -`export default function () {}` with `export default main(function* () {})`, -wrap the async path in `yield* group(...)`, and replace `.then()` chains with -`yield* until(...)`. +To adapt your own scripts: replace `export default function () {}` with +`export default main(function* () {})`, wrap async paths in +`yield* group(...)`, and replace `.then()` chains with `yield* until(...)`. If you maintain k6 or Sobek, please review the PRs and the conformance cases. The runtime boundary is where this guarantee has to hold, or it will leak diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index 99f26b6cb..cf273b39b 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -1,6 +1,4 @@ - - @@ -25,33 +23,18 @@ - - - - - - - - - + - - - - + + + @@ -59,466 +42,384 @@ - + - + - .svg-grid { - opacity: 0.20; - } - .svg-grid-line { - stroke: #374151; - } + + + - .svg-card { - fill: #1f2937; - } - .svg-divider { - stroke: #374151; - } + + Effection Blog + Missing Guarantees in k6's JS Runtime - .svg-dashed-boundary { - stroke: #4b5563; - } - .svg-leak-wire { - stroke: #fb923c; - } - .svg-leak-dot { - fill: #fb923c; - } - .svg-leak-text { - fill: #fb923c; - } + + - .svg-scope-parent { - fill: #111827; - stroke: #374151; - } - .svg-scope-child { - fill: #1f2937; - stroke: #60a5fa; - } - .svg-dot-primary { - fill: #60a5fa; - } - .svg-check-text { - fill: #4ade80; - } - .svg-contained-wire { - stroke: #60a5fa; - } + + + + + + + - .svg-title { - fill: #f3f4f6; - } - .svg-subtitle { - fill: #9ca3af; - } - .svg-caption { - fill: #d1d5db; - } - .svg-label { - fill: #9ca3af; - } - .svg-mono { - fill: #e5e7eb; - } - .svg-mono-sm { - fill: #e5e7eb; - } - .svg-warn-text { - fill: #fb923c; - } - } - - + + Async/Await - - - - - - - - - - - - - - - - - + + + + group: coolgroup + + + + group: — - - Effection Blog - Missing Guarantees - in k6's JS Runtime + + + + + c.add(1) + tagged ✓ + + + + + + http.asyncRequest("GET", url) + + + + + + // group() saves current tags + + + + + + // group() exits, tags cleared + + + + + + .then(() => c.add(1)) + tag already gone ✗ + + + + + + execution order + + + + + + + + + + + Structured Concurrency + + + + + group: coolgroup + + + + group: — + + + + + + + c.add(1) + tagged ✓ + + + + + + yield* until(http.asyncRequest(...)) + - - - + + + + c.add(1) + still tagged ✓ - - + + + + + // scope owns async, waits for completion - - - - - 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. + + + + // scope exits, tags cleared + + + + + + execution order + From bc837db91ef0156f2d947e320e4cf7ca90c365cb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:37:24 -0500 Subject: [PATCH 22/27] blog: improve SVG thumbnail layout and readability - Left-align all headings - Update title: 'Missing Structured Concurrency Guarantees in Sobek' - Left-align step text (remove awkward centered comments) - Widen tag pills to fit 'group: coolgroup' text - Add result summary labels at bottom of each card - Fix card containers and spacing --- .../k6-structured-concurrency.svg | 172 +++++++++--------- 1 file changed, 84 insertions(+), 88 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index cf273b39b..dca4c7d51 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -48,7 +48,7 @@ x="-20" y="-20" width="640" - height="500" + height="520" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB" > @@ -66,7 +66,7 @@ x="-20" y="-20" width="640" - height="500" + height="520" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB" > @@ -83,37 +83,42 @@ /* ---- Font stacks ---- */ .svg-title { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; - font-size: 38px; + font-size: 32px; font-weight: 800; letter-spacing: -0.02em; } + .svg-title-sub { + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + } .svg-subtitle { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; - font-size: 16px; + font-size: 14px; font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.04em; text-transform: uppercase; } .svg-section-title { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; - font-size: 18px; + font-size: 16px; font-weight: 700; } .svg-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 14px; + font-size: 13px; font-weight: 500; } .svg-tag { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 11px; + font-size: 10px; font-weight: 600; } - .svg-comment { + .svg-status { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; - font-size: 12px; - font-weight: 500; - font-style: italic; + font-size: 11px; + font-weight: 600; } /* ---- Light mode (default) ---- */ @@ -126,6 +131,7 @@ .svg-card-stroke { stroke: #e2e8f0; stroke-width: 1; } .svg-title { fill: url(#ink); } + .svg-title-sub { fill: #475569; } .svg-subtitle { fill: #64748b; } .svg-section-title-bad { fill: #dc2626; } .svg-section-title-good { fill: #16a34a; } @@ -133,20 +139,18 @@ .svg-step-box { fill: #f8fafc; stroke: #cbd5e1; stroke-width: 1; } .svg-step-box-active { fill: #eff6ff; stroke: #3b82f6; stroke-width: 2; } .svg-step-box-bad { fill: #fef2f2; stroke: #fca5a5; stroke-width: 2; } + .svg-step-box-comment { fill: #fafafa; stroke: #e5e5e5; stroke-width: 1; stroke-dasharray: 4 2; } .svg-mono { fill: #334155; } - .svg-comment { fill: #94a3b8; } - .svg-comment-bad { fill: #f87171; } + .svg-status-good { fill: #16a34a; } + .svg-status-bad { fill: #dc2626; } .svg-tag-good { fill: #16a34a; } .svg-tag-bad { fill: #dc2626; } + .svg-tag-muted { fill: #64748b; } .svg-tag-bg-good { fill: #dcfce7; stroke: #86efac; stroke-width: 1; } - .svg-tag-bg-bad { fill: #fee2e2; stroke: #fca5a5; stroke-width: 1; } .svg-tag-bg-empty { fill: #f1f5f9; stroke: #cbd5e1; stroke-width: 1; stroke-dasharray: 4 2; } - .svg-arrow { stroke: #94a3b8; stroke-width: 2; fill: none; } - .svg-arrow-head { fill: #94a3b8; } - .svg-divider { stroke: #e2e8f0; stroke-width: 2; stroke-dasharray: 8 4; } /* ---- Dark mode ---- */ @@ -160,6 +164,7 @@ .svg-card-stroke { stroke: #334155; } .svg-title { fill: #f1f5f9; } + .svg-title-sub { fill: #94a3b8; } .svg-subtitle { fill: #94a3b8; } .svg-section-title-bad { fill: #f87171; } .svg-section-title-good { fill: #4ade80; } @@ -167,25 +172,22 @@ .svg-step-box { fill: #0f172a; stroke: #334155; } .svg-step-box-active { fill: #1e3a5f; stroke: #3b82f6; } .svg-step-box-bad { fill: #3f1d1d; stroke: #f87171; } + .svg-step-box-comment { fill: #1e293b; stroke: #475569; } .svg-mono { fill: #e2e8f0; } - .svg-comment { fill: #64748b; } - .svg-comment-bad { fill: #f87171; } + .svg-status-good { fill: #4ade80; } + .svg-status-bad { fill: #f87171; } .svg-tag-good { fill: #4ade80; } .svg-tag-bad { fill: #f87171; } + .svg-tag-muted { fill: #94a3b8; } .svg-tag-bg-good { fill: #14532d; stroke: #22c55e; } - .svg-tag-bg-bad { fill: #450a0a; stroke: #f87171; } .svg-tag-bg-empty { fill: #1e293b; stroke: #475569; } - .svg-arrow { stroke: #64748b; } - .svg-arrow-head { fill: #64748b; } - .svg-divider { stroke: #334155; } } /* ---- Animation keyframes (10s cycle) ---- */ - /* Left side: async/await broken flow */ @keyframes step-L1 { 0%, 4% { opacity: 0; } 5%, 80% { opacity: 1; } @@ -212,7 +214,6 @@ 85%, 100% { opacity: 0; } } - /* Tag state for left side: present at start, gone by step 4 */ @keyframes tag-L-good { 0%, 4% { opacity: 0; } 5%, 34% { opacity: 1; } @@ -224,7 +225,6 @@ 85%, 100% { opacity: 0; } } - /* Right side: structured concurrency working flow */ @keyframes step-R1 { 0%, 4% { opacity: 0; } 5%, 80% { opacity: 1; } @@ -251,7 +251,6 @@ 85%, 100% { opacity: 0; } } - /* Tag state for right side: present through step 4, then cleared */ @keyframes tag-R-good { 0%, 4% { opacity: 0; } 5%, 54% { opacity: 1; } @@ -263,7 +262,6 @@ 85%, 100% { opacity: 0; } } - /* Apply animations */ .anim-L1 { opacity: 0; animation: step-L1 10s ease infinite; } .anim-L2 { opacity: 0; animation: step-L2 10s ease infinite; } .anim-L3 { opacity: 0; animation: step-L3 10s ease infinite; } @@ -280,7 +278,6 @@ .anim-tag-R-good { opacity: 0; animation: tag-R-good 10s ease infinite; } .anim-tag-R-cleared { opacity: 0; animation: tag-R-cleared 10s ease infinite; } - /* Accessibility: respect reduced motion preference */ @media (prefers-reduced-motion: reduce) { .anim-L1, .anim-L2, .anim-L3, .anim-L4, .anim-L5, .anim-R1, .anim-R2, .anim-R3, .anim-R4, .anim-R5, @@ -297,129 +294,128 @@ - - Effection Blog - Missing Guarantees in k6's JS Runtime + + Effection Blog + Missing Structured Concurrency Guarantees + in Sobek — k6's JavaScript Runtime - + - + - + - - Async/Await - - + + Async/Await + + - - group: coolgroup + + group: coolgroup - - group: — + + group: (empty) - - c.add(1) - tagged ✓ + + c.add(1) + tagged ✓ - - http.asyncRequest("GET", url) + + http.asyncRequest("GET", url) - + - - // group() saves current tags + + group() saves current tags - + - - // group() exits, tags cleared + + group() exits → tags cleared! - - .then(() => c.add(1)) - tag already gone ✗ + + .then(() => c.add(1)) + not tagged ✗ - - - - execution order + + Callback runs after group exited + Tag was already cleared - + - + - - Structured Concurrency + + Structured Concurrency - + - - group: coolgroup + + group: coolgroup - - group: — + + group: (cleared) - - c.add(1) - tagged ✓ + + c.add(1) + tagged ✓ - + - - yield* until(http.asyncRequest(...)) + + yield* until(http.asyncRequest(...)) - + c.add(1) - still tagged ✓ + still tagged ✓ - + - - // scope owns async, waits for completion + + scope waits for async to complete - - // scope exits, tags cleared + + scope exits → tags cleared - - - - execution order + + Async completes before scope exits + Tags preserved through entire operation From 38230450f6c1ee6748ae1249c4e7968b74ee7a9b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:49:17 -0500 Subject: [PATCH 23/27] blog: fix SVG rendering and remove cryptic closing line - Remove CSS animations that caused content to be invisible on load - Make all content static and visible by default - Fix shadow filter bounds (use objectBoundingBox) - Adjust card heights to prevent overlap with result labels - Remove 'When the invariant holds, async stops lying' closing line (needs more thought, will revisit later) --- .../index.md | 2 - .../k6-structured-concurrency.svg | 260 +++++------------- 2 files changed, 67 insertions(+), 195 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 65ffdcff2..937ee837d 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -190,5 +190,3 @@ To adapt your own scripts: replace `export default function () {}` with If you maintain k6 or Sobek, please review the PRs and the conformance cases. The runtime boundary is where this guarantee has to hold, or it will leak everywhere above it. - -When the invariant holds, async stops lying. diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index dca4c7d51..5173b2e59 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -45,12 +45,11 @@ @@ -304,118 +210,86 @@ - + - + Async/Await - - - - group: coolgroup - - - - group: (empty) - + + + group: (lost) - - - - c.add(1) - tagged ✓ - + + + c.add(1) + tagged ✓ - - - http.asyncRequest("GET", url) - + + http.asyncRequest("GET", url) - - - group() saves current tags - + + group() saves current tags - - - group() exits → tags cleared! - + + group() exits → tags cleared! - - - .then(() => c.add(1)) - not tagged ✗ - + + .then(() => c.add(1)) + not tagged ✗ - - Callback runs after group exited - Tag was already cleared + + Callback runs after group exited + Metric missing the group tag - + - + Structured Concurrency - - - - group: coolgroup - - - - group: (cleared) - + + + group: coolgroup - - - - c.add(1) - tagged ✓ - + + + c.add(1) + tagged ✓ - - - yield* until(http.asyncRequest(...)) - + + yield* until(http.asyncRequest(...)) - - - c.add(1) - still tagged ✓ - + + c.add(1) + still tagged ✓ - - - scope waits for async to complete - + + scope waits for async to complete - - - scope exits → tags cleared - + + scope exits → tags cleared - - Async completes before scope exits - Tags preserved through entire operation + + Async completes before scope exits + All metrics properly tagged From 26d3305c84b8615c8fb3df04f52b7a1e112fd28a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:54:11 -0500 Subject: [PATCH 24/27] blog: restore working SVG animation - Fix animation by using proper keyframe timing - Steps appear sequentially over 8-second cycle - Left tag changes from green 'coolgroup' to red '(lost)' at step 4 - Right tag stays green 'coolgroup' through async, then '(done)' - Result summaries appear after all steps - Smooth loop with reset phase --- .../k6-structured-concurrency.svg | 290 +++++++++--------- 1 file changed, 148 insertions(+), 142 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index 5173b2e59..30e3a88fa 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -10,28 +10,14 @@ > - + - + @@ -43,86 +29,23 @@ - - + + - - - + + @@ -200,7 +180,7 @@ - + Effection Blog Missing Structured Concurrency Guarantees in Sobek — k6's JavaScript Runtime @@ -216,39 +196,52 @@ - + Async/Await - - - group: (lost) + + + + group: coolgroup + + + + group: (lost) + - - - - c.add(1) - tagged ✓ + + + + c.add(1) + tagged ✓ + - - - http.asyncRequest("GET", url) + + + http.asyncRequest("GET", url) + - - - group() saves current tags + + + group() saves current tags + - - - group() exits → tags cleared! + + + group() exits → tags cleared! + - - - .then(() => c.add(1)) - not tagged ✗ + + + .then(() => c.add(1)) + not tagged ✗ + - - Callback runs after group exited - Metric missing the group tag + + + Callback runs after group exited + Metric missing the group tag + @@ -258,38 +251,51 @@ - + Structured Concurrency - - - group: coolgroup + + + + group: coolgroup + + + + group: (done) + - - - - c.add(1) - tagged ✓ + + + + c.add(1) + tagged ✓ + - - - yield* until(http.asyncRequest(...)) + + + yield* until(http.asyncRequest(...)) + - - - c.add(1) - still tagged ✓ + + + c.add(1) + still tagged ✓ + - - - scope waits for async to complete + + + scope waits for async to complete + - - - scope exits → tags cleared + + + scope exits → tags cleared + - - Async completes before scope exits - All metrics properly tagged + + + Async completes before scope exits + All metrics properly tagged + From 80c0f2d1364d7bce89e505d3c23d1e5ef21b34cd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:56:16 -0500 Subject: [PATCH 25/27] chore: apply deno fmt --- .../index.md | 17 +- .../k6-structured-concurrency.svg | 874 ++++++++++++++---- 2 files changed, 728 insertions(+), 163 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 937ee837d..6357b488c 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -148,18 +148,19 @@ includes a conformance suite that locks these semantics as integration evolves. ## Why Effection for k6? -Every framework that handles concurrent work eventually faces this choice: -keep patching async edge cases one by one, or adopt a model that eliminates the +Every framework that handles concurrent work eventually faces this choice: keep +patching async edge cases one by one, or adopt a model that eliminates the category of problems. Kotlin, Swift, Python, and Java all chose structured concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. Effection is a structured concurrency library for JavaScript, designed as a polyfill until the language adopts these semantics natively. It's tiny (<5k gzipped), mature (used in production since 2019), and easy to drop in and -experiment with. If you know `async/await`, the translation is mostly mechanical: -`async function` becomes `function*`, `await` becomes `yield*`. The Effection -docs include a [Rosetta Stone](https://frontside.com/effection/docs/rosetta-stone) -that maps common async patterns to their structured equivalents. +experiment with. If you know `async/await`, the translation is mostly +mechanical: `async function` becomes `function*`, `await` becomes `yield*`. The +Effection docs include a +[Rosetta Stone](https://frontside.com/effection/docs/rosetta-stone) that maps +common async patterns to their structured equivalents. Effection's goal is to make this choice easy and safe until these guarantees are added to the JavaScript runtime. Its low learning curve and small footprint make @@ -184,8 +185,8 @@ docker compose run --rm k6-demo 01-group-context.js ``` To adapt your own scripts: replace `export default function () {}` with -`export default main(function* () {})`, wrap async paths in -`yield* group(...)`, and replace `.then()` chains with `yield* until(...)`. +`export default main(function* () {})`, wrap async paths in `yield* group(...)`, +and replace `.then()` chains with `yield* until(...)`. If you maintain k6 or Sobek, please review the PRs and the conformance cases. The runtime boundary is where this guarantee has to hold, or it will leak diff --git a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg index 30e3a88fa..d2fbd3f8a 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg +++ b/www/blog/2026-02-15-k6-structured-concurrency/k6-structured-concurrency.svg @@ -10,14 +10,28 @@ > - + - + @@ -29,273 +43,823 @@ - - + + - - + + - - + + Effection Blog - Missing Structured Concurrency Guarantees - in Sobek — k6's JavaScript Runtime + Missing Structured Concurrency Guarantees + in Sobek — k6's JavaScript Runtime - + - + - Async/Await - + Async/Await + - - group: coolgroup + + group: coolgroup - - group: (lost) + + group: (lost) - + c.add(1) - tagged ✓ + tagged ✓ - + http.asyncRequest("GET", url) - group() saves current tags + group() saves current tags - - group() exits → tags cleared! + + group() exits → tags cleared! - + .then(() => c.add(1)) - not tagged ✗ + not tagged ✗ - Callback runs after group exited - Metric missing the group tag + Callback runs after group exited + Metric missing the group tag - + - + - Structured Concurrency + Structured Concurrency - - group: coolgroup + + group: coolgroup - - group: (done) + + group: (done) - + c.add(1) - tagged ✓ + tagged ✓ - - yield* until(http.asyncRequest(...)) + + yield* until(http.asyncRequest(...)) - + c.add(1) - still tagged ✓ + still tagged ✓ - scope waits for async to complete + scope waits for async to complete - scope exits → tags cleared + scope exits → tags cleared - Async completes before scope exits - All metrics properly tagged + Async completes before scope exits + All metrics properly tagged - From 784d327d9cf3aa1643c1ed0e3d5c8604e9aff7a4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 15 Feb 2026 16:59:16 -0500 Subject: [PATCH 26/27] fix: escape '<5k' that breaks MDX parser --- www/blog/2026-02-15-k6-structured-concurrency/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index 6357b488c..c31b398ab 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -154,8 +154,8 @@ category of problems. Kotlin, Swift, Python, and Java all chose structured concurrency. JavaScript doesn't have it built in yet, and TC39 isn't close. Effection is a structured concurrency library for JavaScript, designed as a -polyfill until the language adopts these semantics natively. It's tiny (<5k -gzipped), mature (used in production since 2019), and easy to drop in and +polyfill until the language adopts these semantics natively. It's tiny (under +5kb gzipped), mature (used in production since 2019), and easy to drop in and experiment with. If you know `async/await`, the translation is mostly mechanical: `async function` becomes `function*`, `await` becomes `yield*`. The Effection docs include a From 72b1e6a27c18ad09df6ed4c920b284fb9fe538a5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 16 Feb 2026 07:59:08 -0500 Subject: [PATCH 27/27] blog: add audience callout and link to k6 GitHub issues - Add explicit audience callout for k6 users debugging flaky tests - Reference k6 issues #2848, #5435, #5249, #5524 as evidence of the pattern --- .../2026-02-15-k6-structured-concurrency/index.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/www/blog/2026-02-15-k6-structured-concurrency/index.md b/www/blog/2026-02-15-k6-structured-concurrency/index.md index c31b398ab..7cf83dfd8 100644 --- a/www/blog/2026-02-15-k6-structured-concurrency/index.md +++ b/www/blog/2026-02-15-k6-structured-concurrency/index.md @@ -7,7 +7,9 @@ image: "k6-structured-concurrency.svg" --- 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()`. +metrics not getting tagged inside of `group()` because of async or `.then()`. If +you've debugged flaky load tests or wondered why your dashboards show metrics +outside the groups you put them in, this post explains why — and shows a fix. This happens because JavaScript treats sync and async differently. What you expect to work with sync `group()` doesn't work once async gets introduced. @@ -41,7 +43,12 @@ Maintainers explored making `group()` "wait" for async work. It sounds simple until you hit the corner cases: which promises count, how far transitive waiting goes, what to do with detached callbacks, what to do with timers, what to do with user abstractions built on top of all of that. You patch one path, another -leaks. +leaks. This pattern shows up repeatedly in k6's issue tracker: +[#2848](https://github.com/grafana/k6/issues/2848), +[#5435](https://github.com/grafana/k6/issues/5435), +[#5249](https://github.com/grafana/k6/issues/5249), +[#5524](https://github.com/grafana/k6/issues/5524) — all variations of async +work escaping its logical scope. This is not a k6-specific bug; it is what unstructured async does. Once work can be scheduled to run later—callbacks, promises, futures—it can outlive the task