A Go js/wasm callback waits on a channel, the tab freezes, and the console logs that all goroutines are asleep. Why?
answer
- one thread, shared with the page
- ask who is holding the event loop
- the wrapper must return before it can wait
- start a goroutine, deliver the answer later
- js.FuncOf's own doc warns about this
basics
~20 sThe browser target has one thread, shared with the page's event loop, and a call from JavaScript into Go holds that loop until it returns. Anything the callback waits on needs a later browser event, which can never run.
solid answer
~50 sOn `GOOS=js GOARCH=wasm` there is one thread, and it is the page's event-loop thread. When JavaScript calls a function wrapped by `js.FuncOf`, the Go code runs *inside* that call: the event loop is paused until the wrapper returns. So if the callback blocks waiting for something that can only be delivered by a later browser event — a `time.After`, a fetch-backed `http.Get`, a channel another goroutine fills only when a promise settles — the delivery needs the event loop the callback is holding. Every goroutine parks, the runtime's deadlock detector fires, and the fatal error goes through the glue to the devtools console. The fix is the one `js.FuncOf`'s own documentation gives: a callback that must wait starts a goroutine and returns immediately, delivering the result later through a JavaScript callback or a promise it resolves. Separately, long CPU-bound work does not deadlock but still freezes the page, because there is no second thread — that needs a Web Worker.
code
go · 10 linesfunc validate(this js.Value, args []js.Value) any {
doc := args[0].String() // copy out now: handles are valid only during the call
done := args[1] // a JS callback supplied by the caller
go func() {
// safe to block here: the event loop is free again
result := checkAgainstRemoteSchema(doc)
done.Invoke(result)
}()
return nil
}go deeper
Remember the single-thread rule: a browser Go module has one execution thread and shares it with the page, so a callback that waits inside the call has nobody left to deliver what it is waiting for.
Explain the handover: blocking every goroutine returns control to the event loop, whereas blocking inside a JavaScript-to-Go call keeps the loop paused, so asynchronous host work can never complete.
Diagnose from the console: a goroutines-asleep fatal error means a callback waited on the host, while a silent freeze means CPU starvation on the shared thread. Then restructure the callback to return first and deliver later.
Set the boundary convention before the code spreads: every exported function is asynchronous, results arrive by callback or promise, and heavy work lives in a worker. Retrofitting that across an existing exported surface is expensive.
## One thread, and it is not yours The browser WebAssembly target has no OS threads. The Go runtime, the garbage collector and every goroutine share a single execution thread, and that thread is the page's main thread — the same one that runs the event loop, layout and painting. Concurrency still works: goroutines interleave, channels synchronise, `select` behaves normally. Parallelism does not exist, and raising `GOMAXPROCS` changes nothing, because there is no second thread for the runtime to schedule onto. The cooperative arrangement with JavaScript is what makes callbacks work at all. When Go has nothing runnable — every goroutine blocked — the runtime returns out of WebAssembly, and the browser is free to run other tasks. A later event, such as a call into a registered wrapper or a timer firing, re-enters Go and resumes the scheduler. That is precisely why a module can end `main` with `select {}` and still respond to calls: blocking every goroutine hands the thread back. ## Why a blocking callback is different A call from JavaScript into a `js.FuncOf` wrapper is the opposite direction. The documented behaviour is that invoking the wrapper pauses the event loop and runs the Go function; other wrapped functions triggered during that call run on the same goroutine; and as a consequence, if one wrapped function blocks, the event loop is blocked until it returns. Calling any asynchronous JavaScript API from inside such a call — which is to say anything the browser only completes by running a later task — deadlocks immediately. Work through the concrete case. A validator's callback needs a remote schema, so it calls `http.Get`. On this target the HTTP client is implemented over the browser's fetch API, which returns a promise; the Go side blocks on a channel that will be filled when the promise settles. Settling the promise requires the event loop to run a microtask. The event loop is inside your call and cannot run it. The main goroutine is parked in `select {}`; the callback goroutine is parked on the channel; nothing is runnable. The runtime notices every goroutine is asleep and prints its fatal deadlock error, which the glue forwards to `console.error`. The tab is now wedged. A `time.Sleep` in the callback fails the same way, because the sleep is implemented with a host timer that only fires from the event loop. ## The correct shape Return to the event loop first, then block. The callback starts a goroutine, hands it whatever it needs, and returns immediately — usually returning either nothing plus a caller-supplied JavaScript callback to invoke later, or a promise the goroutine resolves. Once the wrapper has returned, the event loop is free, the fetch can settle, Go is re-entered, and the goroutine proceeds normally. Two details go with it. First, any wrapper you construct per call, such as a promise executor, must have `Release` called on it once it can no longer fire, or you leak. Second, do the `js.Value` reads you need from the arguments *before* returning: argument handles are only valid for the duration of the call, so copy the string or the bytes into Go memory synchronously and let the goroutine work on the copy. ## Reading the symptom The devtools console is the only log sink here, and it is enough to tell the two failure modes apart. “All goroutines are asleep” with a goroutine dump means a genuine deadlock: something inside a call from JavaScript waited for JavaScript. A frozen tab with **no** fatal error, and Go eventually finishing, is the other case entirely — a long computation monopolising the only thread. That one is not a bug in your channel usage; it is the absence of a second thread, and the answer is to move the module into a Web Worker so the computation runs off the page's main thread, or to chop the work into chunks that yield back to the event loop between slices. ## The rule to carry away On this target, treat a call from JavaScript into Go the way you would treat code running in an interrupt handler: do the minimum, do not wait for the outside world, and arrange for the answer to be delivered later.
- The tab freezes but no fatal error appears and the work eventually finishes. What is different?That is not a deadlock but thread starvation. A CPU-bound goroutine is monopolising the only execution thread, which is also the page's main thread, so rendering and input stall until it finishes. Raising GOMAXPROCS cannot help. Move the module into a Web Worker, or slice the work so it yields back to the event loop periodically.
- Why is blocking forever in main safe when blocking in a callback is not?Because parking every goroutine is how Go hands the thread back. When nothing is runnable the runtime returns out of WebAssembly and the browser resumes its event loop, re-entering Go when a call or timer arrives. A blocked callback is the opposite: it is still inside a JavaScript-to-Go call, so the loop cannot resume.
- Can you read a js.Value argument inside the goroutine you spawned?No. Argument handles are only valid for the duration of the callback, so copy what you need into Go memory before returning — the string, or the bytes via a copy out of a Uint8Array. Values you fetched and stored deliberately, such as a caller-supplied callback function, remain usable to invoke later.
It is like a receptionist who answers the phone and then, still holding the line open, waits for a letter that can only be delivered by the receptionist. Hang up first, then wait.
saying these in an interview costs you the question
- Raises GOMAXPROCS hoping to unblock the callback
- Blames the browser rather than the blocking call
- Wraps the blocking wait in a select with a timeout
- Thinks goroutines run in parallel in a browser page
- Reads js.Value arguments after the callback has returned