Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3b11f0b
blog: add k6 structured concurrency case study
taras Feb 15, 2026
7e5e1da
style: apply deno fmt to SVG
taras Feb 15, 2026
f3e5f64
blog: reframe k6 post around runtime guarantees
taras Feb 15, 2026
2c7fcc9
blog: tighten voice and maintainer references
taras Feb 15, 2026
845d33a
blog: rewrite k6 post with cohesive arc
taras Feb 15, 2026
6f661d7
blog: align voice with expectations framing
taras Feb 15, 2026
9e31cf5
blog: illustrate stack deformation across ticks
taras Feb 15, 2026
3eef9c5
blog: rename next tick to future tick
taras Feb 15, 2026
033ca80
blog: tighten stack deformation sentence
taras Feb 15, 2026
d199547
blog: clarify tags differ across stacks
taras Feb 15, 2026
5049df1
blog: clarify why group() can't cover async
taras Feb 15, 2026
68480da
blog: reframe group() as iceberg
taras Feb 15, 2026
0857b4d
blog: rewrite k6 post with tighter structure
taras Feb 15, 2026
ec9ec46
blog: use real k6 APIs, clarify universal solution
taras Feb 15, 2026
6482c21
blog: restore original introduction
taras Feb 15, 2026
97d7cbd
blog: add Why Effection section, improve flow
taras Feb 15, 2026
9487a6a
blog: add preamble with test suite link before code example
taras Feb 15, 2026
fe3d9a6
blog: improve code example context, add conformance suite story
taras Feb 15, 2026
916489e
blog: move 'Why Effection for k6' after ECMAScript compliance section
taras Feb 15, 2026
fa9754e
blog: reorder 'Why Effection' paragraphs - context first, then solution
taras Feb 15, 2026
88f08dd
blog: fix Try It section and redesign thumbnail SVG
taras Feb 15, 2026
bc837db
blog: improve SVG thumbnail layout and readability
taras Feb 15, 2026
3823045
blog: fix SVG rendering and remove cryptic closing line
taras Feb 15, 2026
26d3305
blog: restore working SVG animation
taras Feb 15, 2026
80c0f2d
chore: apply deno fmt
taras Feb 15, 2026
784d327
fix: escape '<5k' that breaks MDX parser
taras Feb 15, 2026
72b1e6a
blog: add audience callout and link to k6 GitHub issues
taras Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions www/blog/2026-02-15-k6-structured-concurrency/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
---
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 demonstrates a structured fix."
author: "Taras Mankovski"
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()`. 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.

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

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.

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.

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)."

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. 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
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.

## Universal solution: structured concurrency

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 runs its cleanup.

These are the same constraints we expect from sync code, applied consistently to
both sync and async.

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

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";
import http from "k6/http";
import { Counter } from "k6/metrics";

const c = new Counter("my_counter");

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

http.asyncRequest("GET", "https://test.k6.io").then(() => {
c.add(1); // NOT tagged (runs after group() restored tags)
});
});
}
```

And here is the same scenario with `@effectionx/k6`:

```js
import { group, main } from "@effectionx/k6";
import { until } from "effection";
import http from "k6/http";
import { Counter } from "k6/metrics";

const c = new Counter("my_counter");

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

yield* until(http.asyncRequest("GET", "https://test.k6.io"));
c.add(1); // still tagged
});
});
```

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.

## 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.

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.

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
`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.

## 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 (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
[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
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

`@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
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(...)`.

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.
Loading
Loading