Quick Start
Set up your first Wails app.
If you’re an Electron developer considering Wails, you’ll find many familiar patterns alongside significant improvements in bundle size, startup time, and memory usage. This guide helps you transition your Electron skills to Wails v3.
Why Wails?
Migration time: 2-8 hours depending on app complexity
Electron bundles Chromium (browser engine) and Node.js, giving you full JavaScript everywhere but at the cost of bundle size and memory usage.
Your App Code (JavaScript) ↓Chromium Renderer (Browser engine) + Node.js Runtime ↓Operating SystemWails uses Go for backend logic and leverages the system’s native webview (WebKit/Webview2), pairing it with a lightweight bridge to your frontend.
Frontend Code (HTML/CSS/TypeScript) ↓Wails Runtime Bridge ↓Go Backend + System Webview ↓Operating SystemKey differences:
| Electron | Wails | Notes |
|---|---|---|
| Main process (Node.js) | Go backend | Business logic runs here |
| Renderer process | Web frontend | Same HTML/CSS/JS pattern |
ipcMain.handle() | Service methods | RPC-style method binding |
ipcRenderer.invoke() | Auto-generated bindings | Type-safe RPC calls |
| IPC events | Event system | Pub/sub events |
| Electron APIs | Application/Window/Menu objects | More intuitive, object-oriented |
preload.js | Auto-generated bindings | No manual bridge code needed |
| npm dependencies | Go dependencies | Compiled into binary |
my-app/├── main.js # Main process├── preload.js # Preload script├── src/│ └── renderer/ # Frontend code│ ├── index.html│ └── app.js├── package.json└── node_modules/my-app/├── main.go # Entry point├── internal/│ └── services/ # Go services (backend)│ └── app.go├── frontend/ # Frontend code│ ├── src/│ │ └── App.tsx│ ├── package.json│ └── vite.config.ts├── wails.json # Config└── go.modIn Electron, your main process handles window management, menus, and app lifecycle. In Wails, this logic moves to Go services.
Electron (main.js):
const { app, BrowserWindow, ipcMain, Menu } = require('electron')
let mainWindow
app.on('ready', () => { mainWindow = new BrowserWindow({ width: 1024, height: 768, webPreferences: { preload: path.join(__dirname, 'preload.js') } }) mainWindow.loadFile('src/renderer/index.html')})
ipcMain.handle('greet', (event, name) => { return `Hello ${name}!`})
const menu = Menu.buildFromTemplate([ { label: 'File', submenu: [ { label: 'Quit', accelerator: 'CmdOrCtrl+Q', click: () => app.quit() } ] }])Menu.setApplicationMenu(menu)Wails (main.go):
package main
import ( "context" "github.com/wailsapp/wails/v3/pkg/application")
func main() { app := application.New(application.Options{ Name: "My App", Services: []application.Service{ application.NewService(&GreetService{}), }, })
app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "My App", Width: 1024, Height: 768, })
menu := app.NewMenu() fileMenu := menu.AddSubmenu("File") fileMenu.Add("Quit").OnClick(func(ctx *application.Context) { app.Quit() })
app.Run()}
type GreetService struct{}
func (g *GreetService) Greet(name string) string { return "Hello " + name + "!"}Electron requires manual IPC setup with channels. Wails auto-generates type-safe bindings.
Electron:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', { greet: (name) => ipcRenderer.invoke('greet', name)})
// renderer/app.jsasync function handleGreet() { const result = await window.api.greet('World') console.log(result)}Wails:
// main.go - Services are automatically boundtype UserService struct{}
func (u *UserService) Greet(name string) string { return "Hello " + name + "!"}// frontend/src/App.tsx - Auto-generated bindingsimport { Greet } from './bindings/myapp/userservice'
async function handleGreet() { const result = await Greet('World') console.log(result)}Electron:
const { BrowserWindow } = require('electron')
const mainWindow = new BrowserWindow({ width: 1024, height: 768, show: false})
mainWindow.loadFile('index.html')mainWindow.show()
// Secondary windowconst settingsWindow = new BrowserWindow({ width: 500, height: 400})settingsWindow.loadFile('settings.html')Wails:
app := application.New(application.Options{ Name: "My App",})
// Main windowmainWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "My App", Width: 1024, Height: 768, Hidden: true,})mainWindow.Show()
// Secondary windowsettingsWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Settings", Width: 500, Height: 400,})Electron:
const { Menu } = require('electron')
const template = [ { label: 'File', submenu: [ { label: 'Open', accelerator: 'CmdOrCtrl+O', click: () => console.log('Opening...') }, { type: 'separator' }, { label: 'Exit', accelerator: 'CmdOrCtrl+Q', click: () => app.quit() } ] }]
const menu = Menu.buildFromTemplate(template)Menu.setApplicationMenu(menu)Wails:
menu := app.NewMenu()
fileMenu := menu.AddSubmenu("File")
openItem := fileMenu.Add("Open")openItem.Accelerator = "Ctrl+O"openItem.OnClick(func(ctx *application.Context) { println("Opening...")})
fileMenu.AddSeparator()
quitItem := fileMenu.Add("Exit")quitItem.Accelerator = "Ctrl+Q"quitItem.OnClick(func(ctx *application.Context) { app.Quit()})Electron:
const { Tray, Menu, app, BrowserWindow } = require('electron')
const tray = new Tray('icon.png')const contextMenu = Menu.buildFromTemplate([ { label: 'Show', click: () => mainWindow.show() }, { label: 'Quit', click: () => app.quit() }])tray.setContextMenu(contextMenu)Wails:
systray := app.SystemTray.New()systray.SetIcon(iconBytes)
menu := app.NewMenu()menu.Add("Show").OnClick(func(ctx *application.Context) { mainWindow.Show()})menu.Add("Quit").OnClick(func(ctx *application.Context) { app.Quit()})
systray.SetMenu(menu)Electron:
const { dialog } = require('electron')
const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [{ name: 'Images', extensions: ['png', 'jpg'] }]})Wails:
result, err := app.Dialog.OpenFile(application.OpenFileDialogOptions{ Title: "Select an image", Filters: []application.FileFilter{ { DisplayName: "Images", Pattern: "*.png;*.jpg", }, },})Electron:
// Main processipcMain.on('update-status', (event, status) => { console.log(status) event.reply('status-updated', { success: true })})
// Renderer processipcRenderer.send('update-status', 'processing')ipcRenderer.on('status-updated', (event, result) => { console.log(result)})Wails:
// Go backendapp.Event.On("update-status", func(e *application.CustomEvent) { status := e.Data.(string) println(status) app.Event.Emit("status-updated", map[string]bool{"success": true})})// TypeScript frontendimport { Events } from '@wailsio/runtime'
Events.On("update-status", (data) => { console.log(data)})
Events.Emit("update-status", "processing")Set up Wails project
go install github.com/wailsapp/wails/v3/cmd/wails@latestwails3 createMove frontend code
frontend/cd frontend && npm installConvert main.go
main.go with application initializationConvert services
Update IPC calls
from './bindings/<appname>/<service>'Configure build
wails.json with frontend build commandsBuild and test
wails3 dev # Developmentwails3 build # Production buildElectron: Use IPC events Wails: Use events or window methods
// Emit event to all windowsapp.Event.Emit("update-status", "ready")
// Access specific windowwindow := app.Window.Current()window.SetTitle("New Title")Electron: Often stored in userData directory Wails: Use any approach - Wails provides app paths
import "github.com/wailsapp/wails/v3/pkg/options"
// Access app directoriesconfigDir := app.Paths.AppDataElectron: Fork worker processes or use Node.js async Wails: Use goroutines (much lighter weight)
func (s *MyService) LongRunningTask() { go func() { // Do work s.app.Event.Emit("task-complete", result) }()}A typical “Hello World” application:
| Electron | Wails | |
|---|---|---|
| macOS (Universal) | ~200MB | 15-25MB |
| Windows (x64) | ~200MB | 15-25MB |
| Linux (x64) | ~180MB | 12-20MB |
| Startup time | 1-2 seconds | 100-300ms |
| Memory (idle) | 100-200MB | 30-60MB |
Problem: Frontend can’t call backend methods
Solution:
# Regenerate bindingswails3 generate bindings
# Check bindings were createdls frontend/bindings/Problem: Exported method not available in bindings
Solution:
func (s *Service) PublicMethod()*application.Context parameter if needed// ✓ Worksfunc (s *MyService) Greet(name string) string { return "Hello " + name}
// ✗ Doesn't work - privatefunc (s *MyService) secret() {}Problem: Events sent from backend not received in frontend
Solution:
// Go: Check event names matchapp.Event.Emit("my-event", data)
// TypeScript: Must listen before emitEvents.On("my-event", (data) => { console.log(data)})Problem: Resulting binary is too large
Solution:
-ldflags="-s -w" in buildProblem: Electron has more built-in APIs
Solution:
Quick Start
Set up your first Wails app.
Bindings
Learn how to bind Go to frontend.
Events
Master the event system.
Windows
Multi-window applications.
Questions? Ask in Discord or open an issue.