Top-level await in JavaScript is a footgun
gist.github.com
gist.github.com
> A lot of people misunderstood Top-level await is a footgun, including me.
https://gist.github.com/Rich-Harris/41e8ccc755ea232a5e7b88de...When you talk about speculative fetches, are you suggesting that engines would guess at which modules were going to be imperatively imported before actually running the code? That doesn't seem like a general solution, or even a particularly desirable one, but I'm interested to learn what you're referring to.
Yes, that is indeed what I am referring to, as engines already do for HTML.
Speculative fetching is possible in HTML because <link> and <img> and <script> tags etc are declarative. Similarly, modules can be fetched without the code executing because `import` is declarative. How can a browser prefetch modules when it encounters an imperative statement like `x = await import(computedModuleId())` without actually running the code?
See above for a response to your point here.
I'm familiar with the concept, but I didn't think that it was standard practice or heading that way for JS. I also have the impression that it comes with serious overhead of its own, making the "await considered harmful" performance claims valid even in that case.
I don't mean that as an argument: if speculative fetches handle this issue I don't understand how, and I'd really like to know.
In the context being discussed here, it means that `await import('./foo.js')` could kick off a speculative fetch for foo.js and all of its dependencies during the tokenization stage---exactly the same as is done for declarative `import './foo.js'`. (Per spec, the fetch for `import './foo.js'` doesn't happen until parsing, which is generally too late to give a good user experience---i.e., this is something engines will already be doing.) You could even imagine extending this to `await fetch('./foo.json')`, although I imagine that will be more of a stretch.
Of course that doesn't help for dynamic cases like `` await import(`./language-packs/${navigator.language}.js`) ``. But that's exactly as you'd expect: if your application truly depends on runtime-determined resources before it can proceed, then of course it needs to avoid continuing to evaluate code that depends on those resources. Top-level await just gives you a way to express that dependency without wrapping the rest of your app in async functions.
Interesting, and kind of makes sense.
However:
> Of course that doesn't help for dynamic cases like `` await import(`./language-packs/${navigator.language}.js`) ``.
Isn't that the main, possibly only advantage of `await import(...)` though?
For static import names, why write `const x = await import('x');` if you can simply write `import x from 'x';`?
If anything, top level async would be an improvement, as it would give developers no reason to do synchronous IO.
Edit: Additionally, ES6 modules are already imperative. There isn't any way to make them declarative.
Top-level async would make asynchronous IO no different from synchronous IO! It's the worst of both worlds.
> the dependencies are statically analyzable
Exactly – which means modules can be loaded concurrently, without having to execute the code. By contrast, imperative loading has to happen sequentially. I explored this aspect of it in a follow-up: https://gist.github.com/Rich-Harris/41e8ccc755ea232a5e7b88de...
That's false. You could still do plenty of things while this asynchronous I/O is happening (including allowing the user to interact with the page, or load other modules in the background).
> By contrast, imperative loading has to happen sequentially.
Yes, but notably, declarative loads are not blocked on imperative loads.
No it's not. Synchronous IO blocks everything. Asynchronous IO with top level await would still allow things queued in the event loop to execute. Plus, top level await + Promise.all would allow you to do operations concurrently, something that synchronous doesn't.
Unless loading a file is a bottleneck, which I doubt.
Loading a file is a bottleneck in the browser, right?
Using await inside an explicitly async function is great, because it only blocks code inside that function – the effects are localised and much easier to predict/reason about.
> You'll educate some of them, but not all. If you give people tools like with, eval, and top-level await, they will be misused, with bad consequences for users of the web.
This is still a footgun, because it makes it easy for people to introduce wide ranging and sometimes subtle bugs in their software.
I can't even tell whether I'm being sarcastic or not.
await import just makes problems worse and, probably, almost unavoidable.
It didn't stop await inside async functions, and it's not going to stop top-level await.
(The argument also seems to be predicated on some fundamental misunderstandings, in that it thinks everything would have to be sequentialized. See my other replies throughout this thread.)
> This is basically the same old argument against `await` itself: that it allows developers to write bad code
It magnifies the effect to a degree that isn't immediately obvious – hence footgun. Users are the ones who will suffer, therefore it's right that we exercise some restraint.
That's true regardless of whether you use top-level await or not.
Meanwhile, you still have to do more work to load the app in the first place (https://gist.github.com/Rich-Harris/41e8ccc755ea232a5e7b88de...).
I'm genuinely curious about the statement that modules can execute concurrently, and what that means for predictability. Jotted down some thoughts here: https://gist.github.com/Rich-Harris/9a270920e203e6df9477ca02...
Java has a package system not a module system.The difference is important because Java might introduce a module system in the future. And Java isn't built around async programming by default, even loading a jar is synchronous.
This is not a problem in HTML loading, because HTML doesn't have `if` statements.
await import(`${config.providerType}.js`);
and this can't be resolved at tokenization phase (and brute-forcing it with speculative/symbolic execution would be too much of a hack IMHO).But in the case of `await fetch('./foo.json')`, fetch is a global. What happens if my code overwrites window.fetch and redirects ./foo.json to ./bar.json? Won't the engine try to fetch foo.json (which might not exist) when it should wait for the code to execute?
I think it's pretty unlikely we'd see this kind of speculative preloading for fetch though, at least initially. More likely, it's done for imports, and then some number of months or years down the line some engineer or PM has the bright idea to measure how much the average web page could be improved by doing speculative preloading for fetch, and then if the measurements are favorable and the technical tradeoffs are manageable, it happens.
Since async/await deal with promises, there's nothing stopping anyone from not awaiting a dependency at the top level so that some loading logic can be run.
In addition, since these are promises, there's nothing stopping us from awaiting multiple promises at once in parallel like so:
const [dependency1, dependency2] = await Promise.all([promise1, promise2]);
All await does is schedule the resumption of the code following an await later on, so any JS not depending on this blocking dependency can continue to run. That is, await doesn't block the entire execution, unless the rest of the entire app code is sitting behind an await.I don't even understand why top level await is necessary given HTTP 2 server push. The point is that import statements at the top can be statically analyzed so the web server can determine which dependencies to push down to you with your request. That's not quite here yet, but by the time this proposal is ready I'm sure it will be.
I guess if you need to use runtime evaluation for something to determine you're dependencies it makes sense but I've found that's rarely the case.
Can't I already do this by putting a fs.readFileSync call or a synchronous AJAX call in my code?
However, the argument against allowing await in top level code doesn't make much sense if all of these bad side effects already exist.
Again, I don't understand the point of the proposal in this context since nobody should be awaiting a long list of dependencies to load first. If you have 100 dependencies that means you have 100 HTTP requests, which is really a poor design decision. That's why we bundle up dependencies into fewer chunks first before sending them down to the client.
Edit: I understand that this would become a sequential issue if one import leads to a request which leads to further imports which lead to more subsequent requests. My point is that unless the developer(s) really don't understand how async/await works, they wouldn't reasonably design an app like this.
I'm not sure I follow – isn't that the definition of 'dependency'?
> If you have 100 dependencies that means you have 100 HTTP requests, which is really a poor design decision
Agree – and it turns out that's still the case with HTTP2. But bundling doesn't solve the problem of await blocking your app, unfortunately.
Await doesn't actually block the entire app (or JS thread) like synchronous code does. It schedules the code that follows after an await in the same file or in an async function to run later. You probably already understand this, but my point is that other code outside of this context is free to continue running.
Is that right? `await` is non-blocking, no?
In other words, if you put
for (let i = 0; i < 10e8; i++)
// blah
in the body of a module, wouldn't that block dependent modules for the duration of the loop?Would top-level await be different?
edit The premise, as I understand it, is that the module itself is treated as the "currently-executing method" for the purpose of the `await`.
It's not difficult to imagine some code nested deep in some NPM module completely changing the load behaviour of your app without you knowing.
"Any module with a dependency on data that it needs to execute will have to wait for that data before it executes."
That's a good thing.
Doesn't the same risk apply if one of the modules in the graph declaratively imports a module hosted on a slow external CDN?
As far as I'm concerned, an explicit call to `ModuleB.loadData()` is far better than having it happen as part of module loading, without me necessarily being aware of it. Right now, module loading is assured to be synchronous, and losing that certainty seems like a bad idea to me.
If your app is shipped as a single bundle this means your entry point would be unable to run anything while dependencies are awaiting if you use `import` declarations. If your app places runtime dependent imports into a dynamic import like using `import()` as a function, it would not be suspended while waiting.
> your entry point would be unable to run anything while dependencies are awaiting
is very different from the situation with synchronous I/O (in a single-threaded environment).
With sync I/O, not only is your entry point unable to do anything (which is what it chose to do, by using `import` instead of `import()`), your entire app is unable to do anything! So other entry points are also blocked.
Even worse, since sync I/O blocks the event loop, it prevents the user from interacting with your web page or Node app---e.g. scrolling, clicking links, pressing Ctrl+C to exit, and so on.
You can definitely write clean code with only callbacks, but one has to be pretty careful if there are many nested asynchronous actions.
Promises and await make that code very much clearer.
Here is the proposal: https://github.com/tc39/proposal-observable
The semantic is different, but everything a promise can do, an observable can do (with roughly the same amount of code), but the other way around isn't true.
Observables are just better in basically every ways. Real world implementations also have proper error handling and decent APIs to handle aborting -today-, while the TC39 is still jumping back and forth trying to figure out how to handle it in promises so that Fetch can stop being useless.
I haven't yet switched over to await because of things like the issues described in this post - I don't mind async operations being a little clunky because it highlights where they're actually happening. Only using a simple keyword makes me worry that the flow control won't get as much attention. For me, Promises are a happy medium that allow you do to do simultaneous operations (mapping, Promise.all) a lot more easily than you can with callbacks, but more obtrusively than with await.
Easier mental model, no surprises, still easy to organize.
This looks like it would allow some interesting new patterns in web programming, though at the expense of wreaking havoc with traditional execution models.
For example, await in event handlers:
<button onclick="await doOneThing(); doAnotherThing()">
Is the event object still valid when doAnotherThing is called?Or await in script blocks:
<p>Welcome back,
<script>
document.write(await fetchUsername());
</script>
</p>
Look ma, I stalled the page load with no synchronous XHR!I've seen examples of it in JavaScript and the author is always like, "Look at what you normally have to write and now look at what you COULD write with async and await!"
And it's like the same goddamn code with a few minor differences.
If they axed the whole proposal, I could get through this holiday season without even being depressed a little bit.
The amount of visual noise in the promise example cannot be understated.
I did read up on the paper that explains how the compiler rewrites the code before being comfortable using await.
But I just don't write enough asynchronous code to actually benefit that much from async/await, and not all asynchronous patterns map nicely to the hierarchical nature of call graphs for me to take advantage of async/await all that sanely, as I found out from a few attempts to force the issue. And how do I add good debug visualizers to display tasks in flight? How do I serialize out long running async tasks? How do I modify things without breaking that serialization?
Due to this mixing I've seen many mistakes occur using await / async.
Yes because people screw up doesn't mean you don't include / create the feature. But at the same time I find the mixing of the two awkward and confusing at times.
I'm a huge fan of messaging patterns and async / await isn't really useful in a message based system so I don't have much of a dog in this fight.
You mean, you can embed multiple script elements in HTML? Yes. But allowing global await in ES spec means also enabling it in node.