Skip to content

Instantly share code, notes, and snippets.

@bartlomieju
Created March 13, 2026 13:26
Show Gist options
  • Select an option

  • Save bartlomieju/bdc1ef45bd9bba24dc4ed082555092b6 to your computer and use it in GitHub Desktop.

Select an option

Save bartlomieju/bdc1ef45bd9bba24dc4ed082555092b6 to your computer and use it in GitHub Desktop.
Investigation: Deno issue #15176 - Resolving promises in beforeunload event
# Investigation: Deno Issue #15176 — Resolving Promises in `beforeunload` Event
## The Problem
```js
await new Promise(resolve => {
window.onbeforeunload = resolve;
});
```
Errors with: `Top-level await promise never resolved`
This also affects `process.on("beforeExit", resolve)` in Node compat mode.
## Node.js Comparison
Node.js handles the equivalent case correctly:
```js
// Works in Node.js, prints "done"
await new Promise(resolve => { process.on("beforeExit", resolve); });
console.log("done");
```
Node fires `beforeExit` **inside** the event loop drain cycle (as part of libuv).
If the handler schedules new work or resolves a promise, the loop continues.
In Deno, both `beforeunload` and `process.beforeExit` are dispatched **after**
the event loop has already concluded there's nothing left — so any pending
promise waiting on them is treated as an error.
## Architecture
The CLI loop (`cli/lib/worker.rs:896-909`):
```rust
loop {
self.worker.run_event_loop(false).await?; // ← errors here, promise pending
let web_continue = self.worker.dispatch_beforeunload_event()?; // ← never reached
if !web_continue {
let node_continue = self.worker.dispatch_process_beforeexit_event()?;
if !node_continue { break; }
}
}
```
The event loop pending state (`libs/core/runtime/jsruntime.rs:2558-2648`):
`EventLoopPendingState::is_pending()` checks ops, timers, dynamic imports, etc.
None of these know about `beforeunload`/`beforeExit` handlers, so when there are
no pending ops but a top-level await is waiting for one of these events, the loop
concludes prematurely.
The error is raised in `with_event_loop_promise()` (`jsruntime.rs:2031-2056`) and
in `evaluate_module()` (`runtime/worker.rs:829-850`), which does a `select!`
between the module evaluation receiver and the event loop — when the event loop
finishes first and the promise is still pending, it errors.
## Proposed Fix: Generic "before-exit" callback
**Don't make `libs/core/` aware of `beforeunload`/`beforeExit` specifically.**
Instead, add a generic callback mechanism.
Add a callback to `PollEventLoopOptions` (or `JsRuntime`) that fires when
`is_pending()` returns false, **before** returning `Poll::Ready`:
```rust
// In jsruntime.rs, around line 2257:
if !pending_state.is_pending() {
// Give the embedder a chance to inject work before exiting
if let Some(ref cb) = poll_options.before_exit_callback {
if cb(scope) {
// Callback scheduled new work (e.g., beforeunload fired
// and preventDefault was called), go around again
continue;
}
}
return Poll::Ready(Ok(()));
}
```
The runtime layer (`runtime/worker.rs`) would register a callback that dispatches
`beforeunload` + `process.beforeExit`. If the event is cancelled or new work is
scheduled, return `true` to keep the loop alive.
### Why this approach?
- **Same pattern as Node/libuv**: `beforeExit` fires inside the loop drain
- **Generic**: `deno_core` doesn't know about specific events
- **Fixes both**: `beforeunload` and `process.beforeExit` in one shot
- **Simplifies CLI**: The loop in `cli/lib/worker.rs:896-909` collapses to a
single `run_event_loop()` call since beforeunload dispatch moves into the callback
### Alternative (simpler but less correct)
Make `evaluate_module()` not error when the promise is pending and the event loop
drains. Let the CLI loop handle it by dispatching beforeunload and running the
loop again. This is less clean because it splits the concern across layers.
## Key Files
- `libs/core/runtime/jsruntime.rs` — event loop, `EventLoopPendingState`, `is_pending()`, `with_event_loop_promise()`
- `libs/core/error.rs` — `PendingPromiseResolution` error
- `runtime/worker.rs` — `evaluate_module()`, `dispatch_beforeunload_event()`
- `runtime/js/99_main.js` — `dispatchBeforeUnloadEvent()` JS function
- `cli/lib/worker.rs` — CLI main loop (`run()` method, lines 896-909)
- `cli/worker.rs` — CLI worker loop
## Status
Issue has been open since July 2022. Workaround exists: use `addEventListener("beforeunload", e => { e.preventDefault(); })` instead of promise resolution.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment