wailsapp/wails · error
invokeCallback must always be called on the MainOSThread
Error message
invokeCallback must always be called on the MainOSThread
What it means
This panic fires in windowsApp.invokeCallback (v3/pkg/application/mainthread_windows.go:107) when the wmInvokeCallback window message is dispatched on an OS thread that is not the thread that created Wails' hidden main-thread window. Wails Windows marshals cross-thread calls by PostMessage'ing wmInvokeCallback to that window, and the handler asserts via invokeRequired() (which compares m.mainThreadID to w32.GetCurrentThreadId()) that it is running on the main OS thread. The guard protects the shared mainThreadFunctionStore and all queued main-thread closures from being executed on an arbitrary thread.
Source
Thrown at v3/pkg/application/mainthread_windows.go:107
fn()
}
}
func (m *windowsApp) invokeRequired() bool {
mainThreadID := m.mainThreadID
if mainThreadID == 0 {
panic("initMainLoop was not called")
}
return mainThreadID != w32.GetCurrentThreadId()
}
func (m *windowsApp) invokeCallback(wParam, lParam uintptr) {
// TODO: Should we invoke just one or all queued? In v2 we always invoked all pendings...
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if m.invokeRequired() {
panic("invokeCallback must always be called on the MainOSThread")
}
mainThreadFunctionStoreLock.Lock()
fnIDs := make([]uint, 0, len(mainThreadFunctionStore))
for id := range mainThreadFunctionStore {
fnIDs = append(fnIDs, id)
}
sort.Slice(fnIDs, func(i, j int) bool { return fnIDs[i] < fnIDs[j] })
fns := make([]func(), len(fnIDs))
for i, id := range fnIDs {
fns[i] = mainThreadFunctionStore[id]
delete(mainThreadFunctionStore, id)
}
mainThreadFunctionStoreLock.Unlock()
for _, fn := range fns {
fn()View on GitHub (pinned to 0e754b1b40)
Solutions
- Audit the startup path: call app.Run() from the main goroutine exactly as the Wails v3 templates do, and let Wails own initMainLoop/runMainLoop so both run on the same locked OS thread.
- If you must drive the loop yourself, guarantee thread affinity: call runtime.LockOSThread() in main() before any Wails call and keep every message pump for the main-thread window on that same thread.
- Check any custom options.Windows.WndProcInterceptor or third-party subclass: make sure it calls through on the same thread and never re-posts wmInvokeCallback messages to another thread or window.
- Search your code for direct use of application-internal thread helpers (InvokeOnMainThread is safe; anything calling invokeCallback/wmInvokeCallback indirectly is not) and route through the public API.
- If the panic persists, file a Wails issue with the goroutine dump: the stack shows which thread dispatched the message and usually pinpoints the rogue pump.
Example fix
// before
func main() {
go app.Run() // Go scheduler may run this on any OS thread
select {}
}
// after
func main() {
// Wails creates and pumps its main-thread window on the
// goroutine that calls Run; keep that on the main thread.
app.Run()
} Defensive patterns
Strategy: validation
Validate before calling
// Before pumping messages or driving the Wails loop yourself,
// verify you are on the main OS thread that called initMainLoop.
// (Illustrative; the IDs come from w32.GetCurrentThreadId / the
// thread that created the main-thread window.)
func runOnMainThreadGuard(fn func()) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if !isWailsMainThread() { // compare against the thread that ran app init
panic("must run on the Wails main OS thread")
}
fn()
} Try / catch
// Go: recover only to capture diagnostics, then crash deliberately —
// continuing after a main-thread violation corrupts app state.
defer func() {
if r := recover(); r != nil {
log.Printf("main-thread violation: %v\n%s", r, debug.Stack())
os.Exit(1)
}
}() Prevention
- Call app.Run() from the main goroutine exactly as the Wails v3 templates do; never wrap it in 'go'.
- Never build custom message pumps for the Wails main-thread window; use application.InvokeOnMainThread for main-thread work.
- Keep any Windows WndProcInterceptor pass-through and synchronous — do not re-post messages to other threads.
- LockOSThread in main() if your startup performs Win32 calls before app.Run().
- Pin a known-good Wails version and read release notes for threading changes.
When it happens
Trigger: Any setup where the WndProc for the main-thread window (application_windows.go:232 calls m.invokeCallback) runs on a different OS thread than the one that called initMainLoop(): starting the Wails event loop (runMainLoop) from a goroutine that Go migrated to another OS thread without LockOSThread; creating/running the app on one thread and pumping messages on another; a custom WndProcInterceptor or window subclass that forwards wmInvokeCallback messages to another thread's message pump; or embedding Wails in a host app whose main window pump is not the Wails main thread.
Common situations: Embedding a Wails v3 window inside an existing Win32/Qt/SDL host application; calling application.Run from a non-main goroutine 'to avoid blocking'; using experimental setups that spawn their own GetMessage loop; upgrading from v2 where threading expectations were looser; CGO callbacks invoked from worker threads that end up dispatching into the Wails message window.
Related errors
- initMainLoop was not called
- Invalid JSON passed to callback: ${e.message}. Message: ${in
- Callback '${callbackID}' not registered!!!
- Invalid JSON passed to Notify: ${notifyMessage}
- CancellablePromise does not support transparent subclassing.
AI-assisted analysis of wailsapp/wails@0e754b1b40 (2026-08-15).
Data as JSON: /api/errors/7b6bab9857efca2f.
Report an issue: GitHub.