Async `for` loops (allow `await` without blocking the loop)

Example:

for (const user of userList) {
  await pingUser(user);
  console.log(user.name, 'pinged')
}
console.log('done')

Expectations:

  • call pingUser on each item of the Set
  • log user name after each ping
  • log done after they've all been pinged
  • call pingUser "in parallel" (expectation not met)

The closest piece of code I've been using for this pattern is:

await Promise.all([...userList].map(async user => {
  await pingUser(user);
  console.log(user.name, 'pinged');
}));
console.log('done')

This is quite a mouthful and it awkwardly uses .map+.all to await promises.

Possible solution:

for (const user of userList) async {
  await pingUser(user);
  console.log(user.name, 'pinged')
}
console.log('done')

Notes

  1. async here doesn't just define an "async block" but it would be part of the for loop itself; the loop would still need to be awaited as a whole.
  2. this is not equivalent to for () { /* async IIFE */ } because that would not wait for the IIFEs to complete.

Prior art

ESLint offers a no-await-in-loop rule which could be elegantly fixed with this async keyword in some cases

I wonder how much the Async Iterators proposal could help here.

await userList
  .values()
  .map(async (user) => {
    await pungUser(user);
    console.log(user.name, 'pinged');
  })
  .toAsync()
  .toArray();

Might be nice to have some other way to 'end' the chain without toArray, as that will need to buffer all the undefined results here. Maybe toPromise().

Or maybe if there was a concurrent version of forEach, or equivalent, as discussed here.

await userList
  .values()
  .toAsync()
  .forEach(async (user) => {
    await pungUser(user);
    console.log(user.name, 'pinged');
  });

A benefit of using iterator helpers for this, vs for-each syntax, might be being able to control the concurrency.

CC @bakkot , @michael

Iterator helpers get you almost all the way there, but it's awkward:

await userList
  .values()
  .toAsync()
  .map(async (user) => {
    await pungUser(user);
    console.log(user.name, 'pinged');
  })
  .bufferAhead(3); // or whatever degree of concurrency you want
  .forEach(() => {}); // drain

The "almost" qualifier is because this doesn't let you early exit, whereas for does (though it's a bit unclear how that would translate to concurrent a concurrent for).

A concurrent forEach would simplify that a bit. But yeah, syntax would be better still. I've been thinking some about how to do that, though only very speculatively. Do note that you almost certainly want to specify the degree of concurrency explicitly, at least when using a pattern like this.

2 Likes

This is something I've wanted as well. My ideal syntax would probably be along the lines of this:

for parallel (const user of userList) {
  await pingUser(user);
  console.log(user.name, 'pinged')
}
console.log('done')

I like the word "parallel" because 1. We already have a "for await", having another for loop style that uses the word "await" or "async" is just going to feel messy, and 2. what we're specifically trying to do is execute each iteration in parallel instead of in series. Though, on the other hand, it would be kind-of nice if the await keyword was in there somewhere as well, because conceptually we would be awaiting this loop too. But await for parallel (...) { ... } feels a little more messy.

Perhaps an API solution would work better. Not exactly sure what this proposed .bufferAhead() does, but I assume it makes it so only that number of parallel tasks are running at a time? New ones don't start until the old ones are done? (I don't see .bufferAhead() in the current iterators helpers proposal). If that is what it does, then that would sound extremely nice as well, and would make an API version sound nicer than a syntax version (sure you could add the ability to parametarize the syntax with something like that, but that will make the syntax feel much more heavy-weight).


To put some of my own experience into this issue, I've attempted to explain the Promise.all() + array.map() pattern to my teammates, but they seem reluctant to use it, partly because it's fairly clunky, but also because if you don't have a strong grasp on how promises and what-not work, it can be a little difficult to understand what that pattern is doing. And I can sympathise with these issues. So, they prefer creating an empty list, pushing promises into it in a for loop, then await Promise.all() that hand-built list.

With JavaScript being so built on asynchronous programming, it would be nice if there was just a native syntax or API or something that could be used to accomplish this very common task. Something that communicates clearly your intentions, instead of requiring us to either tediously hand-build a promise list, or to use a somewhat-tricky pattern that can be difficult to understand if you don't have a strong grasp of promises yet.

How would break work within the loop?

for parallel (let i = 0; i < 3; i++) {
  console.log(i);
  if (i === 0) {
    try {
       await Promise.resolve(1);
       console.log("resolved");
    } finally {
       console.log("finally");
    }
  }
  else if (i === 1) {
     break;
  }
}
  • A) SyntaxError no break
  • B) logs: 0, 1, finally
  • C) logs: 0, 1, resolved, finally
  • D) other?

Similarly, what happens if the break were throw new Error?

My first reaction would be that break stops the single iteration, exactly like return would in the .map() version. It might make sense to have it be a SyntaxError though.

As for errors, it would behave like the .map version:

  • other iterations continue
  • the for block immediately throws an error
try {
  for parallel (const a of list) {
    if (await a === 0) throw new Error('Zero found')
  }
} catch (error) {
  // Zero found
}

A return could work like a promise race:

  • other iterations continue
  • the function containing the for parallel returns the value.

In that situation it sounds more like continue so yeah maybe better to SyntaxError, rather than have two keywords with the same semantics.

In the .map version if the throw happens before the first await then it would stop the iteration. For the loop would it always let the iteration continue?

You're right, it might make sense to have synchronous returns/throws block the loop:

for parallel (const item of [1,2,3]) {
  console.log(item);
  return item; // or throw
}
// log: 1

but if they come after an await then it would be too late to stop the loop:

for parallel (const item of [1,2,3]) {
  console.log(item);
  await 1;
  return item; // or throw
}
// log: 1
// log: 2
// log: 3

Also I think for parallel would not work because async iterators should still be possible:

for await (const request of httpServer) async {
  await handleRequest(request);
  console.log('request handled', request.id)
}
1 Like

I agree that break should be a syntax error. It doesn't make sense to give one loop iteration the power to try and break the loop when other iterations have already started.

With throw new Error(), we can think of for parallel as implicitly having an await on it, so the first error that happens during iteration can be thrown at the point where the loop was defined, while the rest of the errors will be ignored, similar to how Promise.all() behaves.

Though... what if the user would prefer the semantics of allSettled()? Or one of the other promise functions?

And then comes the question of return. Really, that ought to throw a syntax error if used in a "for parallel" loop as well, since it doesn't really make sense to let different iterations try and return a value.

Then there's the issue that if we really want to match the power of the Promise.all() + array.map(), we need to allow the for loop to be used in an expression position, and allow the loop bodyies to specify a completion value, i.e. something like this:

const listOfResults = for parallel (const x of arrayOfItems) {
  const y = await calcResult(x);
  y;
}

But, I'm sure having loop syntax that can be used in an expression position sometimes and can't other times, depending on if the "parallel" word is present would be a strong no-go.


Basically, all of this is helping me realize more that, while this type of "parallel execution" shares some semantics with for loops, it's also very different, and it would probably cause more confusion than it would solve if we tries to pretend they were similar. If we do want a native syntax, we probably need to drop the "for" word entirely.

const listOfResults = await parallel (const x of arrayOfItems) {
  const y = await calcResult(x);
  y;
}

Either that or use a new API instead of syntax.

(I'm still not certain how best to handle the issue of deciding which promise-aggregation semantics you want, either with syntax, or with an API - Promise.all() is the most common, but other Promise.*() methods are often needed as well).

what if the user would prefer the semantics of allSettled()? Or one of the other promise functions?

They're all possible, but such for parallel pattern is probably not the best solution for that. The point of this proposal would be to abstract promises away, rather than provide exact equivalences.

allSettled

for (const a of abc) async {
  try {
    await a;
  } catch {}
}

race

for (const a of abc) async {
  await a;
  if (logic) {
    return;
  } else {
    throw new Error()
  }
}

any

for (const a of abc) async {
  try {
    await a;
  } catch {
    continue;
  }
  return;
}

throw new Error()

it doesn't really make sense to let different iterations try and return a value

I disagree. If Promise.race(x.map(async () => {return 1})) makes sense, then this does too. Regular for loops can also exit the loop early, even if previous iterations started async operations. If that's important you'd have to set up your own AbortSignal manually anyway.

if we really want to match the power of the Promise.all() + array.map()

If you want to map values, use map. I only used "map" in my example because there's no alternative. This idea does not intend to create "for expressions", which is a whole other can of worms

1 Like

The closest alternative/proposal to this would be await.ops:

await.all [...userList].map(async user => {
  await pingUser(user);
  console.log(user.name, 'pinged');
});
console.log('done')

This is reasonable, but:

  • the .map callback still doesn't explicitly return, so it's not a standard/expected usage (there's a native eslint rule to enforce this)
  • it lacks the ability to return early (early throw is supported)

The Promise.all(Promise<void>[]) pattern is pretty idiomatic. The ESLint rule also doesn't check async functions: https://github.com/eslint/eslint/blob/47a08597966651975126dd6726939cd34f13b80e/lib/rules/array-callback-return.js#L241-L244