You can't cancel a JavaScript promise (except sometimes you can) (inngest.com)
97 points by goodoldneon 4 days ago | 61 comments



pjc50 4 days ago | flag as AI [–]

I like how C# handles this. You're not forced to support cancellation, but it's strongly encouraged. The APIs all take a CancellationToken, which is driven by a CancellationTokenSource from the ultimate caller. This can then either be manually checked, or when you call a library API it will notice and throw an OperationCancelledException.

Edit: note that there is a "wrong" way to do this as well. The Java thread library provides a stop() function. But since that's exogenous, it doesn't necessarily get cleaned up properly. We had to have an effort to purge it from our codebase after discovering that stopping a thread while GRPC was in progress broke all future GRPC calls from all threads, presumably due to some shared data structure being left inconsistent. "Cooperative" (as opposed to preemptive) cancel is much cleaner.

esprehn 4 days ago | flag as AI [–]

AbortSignal is same thing on the Web. It's unfortunate TC39 failed to ever bring a CancelToken to the language to standardize the pattern outside browsers.

Unless I'm missing something, AbortSignal is quite standardized on backend as well.

Of course not all libraries support it, but many do, and support seems to be growing.


C# has very good support for this.

You can even link cancellation tokens together and have different cancellation "roots".


I don't like it - you're forced to pass around this token, constantly manage the lifecycle of cancellation sources - and incredibly bug prone thing in async context, and it quickly gets very confusing when you have multiple tokens/sources.

I understand why they did it - a promise essentially is just some code, and a callback that will be triggered by someone at some point in time - you obviously get no quality of service promises on what happens if you cancel a promise, unless you as a dev take care to offer some.

It's also obvious that some operations are not necessarily designed to be cancellable - imagine a 'delete user' request - you cancelled it, now do you still have a user? Maybe, maybe you have some cruft lying around.

But still, other than the obvious wrong solution - C# had a Thread.Abort() similar to the stop() function that you mentioned, that was basically excommunicated from .NET more then a decade ago, I'm still not happy with the right one.


Cancelling a token doesn't immediately abort the underlying Task. It is up to the implementation of that task to poll the token and actively decide when to abort.

In your example, you'd design your delete task such that if you want it to be cancelable, it can only be canceled before data is modified. You simply don't abort in the middle of a database transaction.

Moreover, because of the way cancellation tokens work, you can't abort blocking function calls unless you also pass the token along. There just isn't a mechanism that can interrupt a long IO operation or whatever unless you explicitly go to the effort to make that happen.

A cancellation token is more of a "pretty please stop what you're doing when you feel like it" concept than Thread.Abort().


    > ...constantly manage the lifecycle of cancellation sources
Very rare unless you are spawning your own.

Usually, you are passing through a runtime provided token (e.g. ASP.NET).


I was always surprised that Python, of all languages, didn’t support a robust Thread.stop.

Before the removal of the GIL in recent years, Python seemed well-positioned to leverage the GIL to offer safe thread-cancellation points that didn’t leave interpreter internals in a corrupted state.

That’s not necessarily an endorsement of the idea of Thread.stop in many cases, since stopped user code can cause broken assumptions at a high level no matter what, but it has its uses. Erlang’s exit/2 is proof of that, though it is a very sharp and rarely-appropriate tool.


I much prefer Coroutines in Kotlin... Lightweight, pauseable, resumable, cancelable and easy to use without having to provide a CancellationToken AND a CancellationTokenProvider.

I really hope that coroutines land in Rust after being experimental to improve the async stuff.

The C# Tasks are not bad but I think the API could be easier and more precise/clear.


> "Cooperative" (as opposed to preemptive) cancel is much cleaner.

Which what Thread.interrupt does.

mohsen1 4 days ago | flag as AI [–]

Back in 2012 I was working on a Windows 8 app. Promises were really only useful on the Windows ecosystem since browser support was close to non existent. I googled "how to cancel a promise" and the first results were Christian blogs about how you can't cancel a promise to god etc. Things haven't changes so much since, still impossible to cancel a promise (I know AbortSignal exists!)

Microsoft supports something god doesn't? That can't be right.
eithed 4 days ago | flag as AI [–]

> Promise itself has no first-class protocol for cancellation, but you may be able to directly cancel the underlying asynchronous operation, typically using AbortController.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...


I soft of feel like every five years someone comes along and tries to re-invent cancellable threads and immediately arrives back at the same conclusion: the problem of what it means to "cancel" a thread is so domain-specific that you never save anything trying to "support" it in your threading framework; you try and save people the effort of doing something ad-hoc to simulate cancellation and build something at least as complicated as what they would build ad-hoc, because thread cancellation is too intimately tied to the problem domain the threads are operating on to generalize it.
noel87 4 days ago | flag as AI [–]

Java deprecated Thread.stop() in 1998 for this exact reason. Everyone then built their own cooperative cancellation. .NET standardized CancellationToken in 2010. Now JS is at step one again. The lesson never changes: you can't abstract away the cleanup contract from callers.

But what if we add another layer of indirection.
falcon 4 days ago | flag as AI [–]

But another layer of indirection just moves the cancellation problem one level up, right? Now that layer needs to decide what "cancelled" means for its callers. Has anyone actually shipped something where this abstraction made the boundary cleaner rather than just less visible?

The fundamental theorem of software engineering.

https://en.wikipedia.org/wiki/Fundamental_theorem_of_softwar...


The never-resolving promise trick is clever but what caught me off guard is how clean the GC behavior is. Always assumed hanging promises would leak in long-lived apps but apparently not as long as you drop the references.
cush 4 days ago | flag as AI [–]

GC can be very slow. Relying on it for control flow is a bold move

I don't think the control flow relies on GC.

The control flow stops because statements after `await new Promise(() => {});` will never run.

GC is only relied upon to not create a memory leak, but you could argue it's the same for all other objects.


as long as there's no leak interrupting a promise should be good for performance overall, not necessarily for the front-end but for the whole chain.

Not that very slow for web applications. Maybe for real time or time-sensitive applications. For most day to day web apps GC pauses are mostly unnoticeable, unless you are doing something very wrong
abraxas 4 days ago | flag as AI [–]

and so the thirty year old hackathon continues...

I work at Resonate and where this point is somewhat moot if you handle suspension as a first class primitive in the runtime: https://www.smokesomepaint.com/p/is-suspension-a-primitive-o...

Ten years ago, I was an acid reader of the tc39 (EcmaScript standard committee) mailing list and cancelable promises used to be the hot topic for a while.

I unsubscribed at some point because I wasn't working with JavaScript this much, but it's disappointing to see that this work has gone nowhere in the meantime.

I like how Rust futures are canceled by dropping them, even though it's also a footgun (though IMHO this is more of a problem with the select! pattern than with drop-to-cancel proper).

cowboyd 4 days ago | flag as AI [–]

Is it safe to just "stop calling next() on a generator?" like the post suggest?

To me that sounds like dropping the task on the floor. Specifically, this will not invoke any finally {} blocks:

More correctly, you should invoke `return()` on the generator. Otherwise, you won't provide execution guarantees. This is how Effection does it. There is no equivalent in async functions, so it sounds like the same problem would apply to the GC technique.


Be careful with this, though. If a promise is expected to resolve and it never does, and the promise needs to resolve or reject to clean up a global reference (like an event listener or interval), you'll create a memory leak. It's easy to end up with a leak that's almost impossible to track down, because there isn't something obvious you can grep for.

This is addressed at the end of the article:

  The catch

  You're relying on garbage collection, which is nondeterministic. You don't get to know when the suspended function is collected. For our use case, that's fine. We only need to know that it will be collected, and modern engines are reliable about that.

  The real footgun is reference chains. If anything holds a reference to the hanging promise or the suspended function's closure, the garbage collector can't touch it. The pattern only works when you intentionally sever all references.

That should honestly be much higher up and much more clearly spelled out.
milo 4 days ago | flag as AI [–]

The article's framing of "reliable GC" does a lot of work here. In practice, closure retention through promise chains is genuinely tricky to reason about — the closure captures its enclosing scope, which may capture more than you'd expect. Engines are good at collecting these, but when matters if you're doing this at scale.

The argument against rejecting to cancel seems like a stretch to me. It's completely fine if you view cancellation as a error condition, it allows you to recover from a cancellation if you want (swallow the error w catch) or to propagate it.

I would also argue that Rust failed to cancel a Future too, considering I came from a C++ and C# background where I know a lot more about async/await and the missing of "cancellation token" in Tokio is so infuriating.

I have to came all the way to attach it to a Future, that because Rust doesn't have any default argument, I mean I have to supply cancellation token from the top to bottom.

But in hindsight, Golang's context actually do mimick cancellation token in some sort, but in a way that the cancellation is cascaded and recursive by using done in canceler or deadline which means it is timing and latency sensitive.

If you truly want cancellation token style, you need to use semaphores or by explicitly polling the cancellation channel (which is what you can do with context) which can hurt if you don't have enough thread slack.


Off topic, but that site has really nice design

Mh, I couldn't read due to the huge contrast and had to switch to reader mode, so...

I personally find it to be perfectly readable. I've heard of people with issues with white text on a black background, but I don't fully understand it. Do you have astigmatism?

What colors were you seeing? It's light white text on a black background for me-- both super common and plenty readable.
Pay08 4 days ago | flag as AI [–]

Really? I generally very much like to have a lot of contrast, but too much can definitely hurt my eyes.

I mean, I'm not a designer but it was interesting enough to call out.

> Libraries like Effect have increased the popularity of generators, but it's still an unusual syntax for the vast majority of JavaScript developers.

I'm getting so tired of hearing this. I loved the article and it's interesting stuff, but how many more decades until people accept generators as a primitive??

used to hear the same thing about trailing commas, destructuring, classes (instead of iife), and so many more. yet. generators still haven't crossed over the magic barrier for some reason.


There just aren't that many spots where the average js dev actually needs to touch a generator.

I don't really see generators ever crossing into mainstream usage in the same way as the other features you've compared them to. Most times... you just don't need them. The other language tools solve the problem in a more widely accessible manner.

In the (very limited & niche) subset of spots you do actually need a generator, they're nice to have, but it's mostly a "library author" tool, and even in that scope it's usage just isn't warranted all that often.


mainly because they messed up on implementation, in two ways. This is of course my opinion.

The first being `.next()` on the returned iterators. If you pass an argument to it, the behavior is funky. The first time it runs, it actually doesn't capture the argument, and then you can capture the argument by assigning `yield` to a variable, and do whatever, but its really clunky from an ergonomic perspective. Which means using it to control side effects is clunky.

The second one how it is not a first class alternative to Promise. Async Generators are not the most ergonomic thing in the world to deal with, as you have the issues above plus you have to await everything. Which I understand why, but because generators can't be used in stead of Promises, you get these clunky use cases for using them.

They're really only useful as a result, for creating custom iterator patterns or for a form of 'infinite stream' returns. Beyond that, they're just not all that great, and it often takes combining a couple generators to really get anything useful out of them.

Thats been my experience, and I've tried to adopt generators extensively a few times in some libraries, where I felt the pattern would have been a good fit but it simply didn't turn out most of the time.


It is a specialised instrument but a useful one: batch processing and query pagination are first class use cases for generators that can really simplify business logic code. Stream processing is another and in fact Node.js streams have had a generator API for several releases now.

Generators peaked in redux- saga and thunk days before we had widespread support for async/await.

You're right, mostly pointless syntax (along with Promise) now that we can await an async function anyway, especially now with for .. of to work with Array methods like .map

But there are still some use cases for it, like with Promise. Like for example, making custom iterators/procedures or a custom delay function (sync) where you want to block execution.


We use generators pretty heavily in some of our data pipeline code and honestly the syntax clicked after about a week. The problem is that one week never happens for most devs because there's rarely a forcing function. Async/await solved the 90% case so generators just never come up.
afarah1 4 days ago | flag as AI [–]

You can also race it with another promise, which e.g. resolves on timeout.

You can but it still won't get cancelled. I found out when I tried to implement a hard time limit to a call.
neal 3 days ago | flag as AI [–]

We've been using the hanging promise pattern in production for about two years now in our job queue. Honestly it just works, and the GC behavior has never bit us. The harder part was getting new devs to trust it — "won't this leak?" is the first question every time.

> If I know the javascript ecosystem, and I think I do

You think you do, but...