Skip to content

Go-Frontend Bridge

Wails provides a direct, in-memory bridge between Go and JavaScript, enabling seamless communication without HTTP overhead, process boundaries, or serialisation bottlenecks.

Diagram

Key insight: No HTTP, no IPC, no process boundaries. Just direct function calls with type safety.

When your application starts, Wails scans your services:

type GreetService struct {
prefix string
}
func (g *GreetService) Greet(name string) string {
return g.prefix + name + "!"
}
func (g *GreetService) Add(a, b int) int {
return a + b
}
// Register service
app := application.New(application.Options{
Services: []application.Service{
application.NewService(&GreetService{prefix: "Hello, "}),
},
})

What Wails does:

  1. Scans the struct for exported methods
  2. Extracts type information (parameters, return types)
  3. Builds a registry mapping method names to functions
  4. Generates TypeScript bindings with full type definitions

Wails generates TypeScript bindings automatically:

frontend/bindings/GreetService.ts
export function Greet(name: string): Promise<string>
export function Add(a: number, b: number): Promise<number>

Type mapping:

Go TypeTypeScript Type
stringstring
int, int32, int64number
float32, float64number
boolboolean
[]TT[]
map[string]TRecord<string, T>
structinterface
time.TimeDate
errorException (thrown)

Developer calls the Go method from JavaScript:

import { Greet, Add } from './bindings/GreetService'
// Call Go from JavaScript
const greeting = await Greet("World")
console.log(greeting) // "Hello, World!"
const sum = await Add(5, 3)
console.log(sum) // 8

What happens:

  1. Binding function called - Greet("World")
  2. Message created - { service: "GreetService", method: "Greet", args: ["World"] }
  3. Sent to bridge - Via WebView’s JavaScript bridge
  4. Promise returned - Awaits response

The bridge receives the message and processes it:

Diagram

Security: Only registered services and exported methods are callable.

The Go method executes:

func (g *GreetService) Greet(name string) string {
// This runs in Go
return g.prefix + name + "!"
}

Execution context:

  • Runs in a goroutine (non-blocking)
  • Has access to all Go features (file system, network, databases)
  • Can call other Go code freely
  • Returns result or error

Result is sent back to JavaScript:

// Promise resolves with result
const greeting = await Greet("World")
// greeting = "Hello, World!"

Error handling:

func (g *GreetService) Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
try {
const result = await Divide(10, 0)
} catch (error) {
console.error("Go error:", error) // "division by zero"
}

Typical call overhead: <1ms

Frontend Call → Bridge → Go Execution → Bridge → Frontend Response
↓ ↓ ↓ ↓ ↓
&lt;0.1ms &lt;0.1ms [varies] &lt;0.1ms &lt;0.1ms

Compared to alternatives:

  • HTTP/REST: 5-50ms (network stack, serialisation)
  • IPC: 1-10ms (process boundaries, marshalling)
  • Wails Bridge: <1ms (in-memory, direct call)

Per-call overhead: ~1KB (message buffer)

Zero-copy optimisation: Large data (>1MB) uses shared memory where possible.

Calls are concurrent:

  • Each call runs in its own goroutine
  • Multiple calls can execute simultaneously
  • No blocking between calls
// These run concurrently
const [result1, result2, result3] = await Promise.all([
SlowOperation1(),
SlowOperation2(),
SlowOperation3(),
])
// Go
func Example(
s string,
i int,
f float64,
b bool,
) (string, int, float64, bool) {
return s, i, f, b
}
// TypeScript (auto-generated)
function Example(
s: string,
i: number,
f: number,
b: boolean,
): Promise<[string, number, number, boolean]>
// Go
func Sum(numbers []int) int {
total := 0
for _, n := range numbers {
total += n
}
return total
}
// TypeScript
function Sum(numbers: number[]): Promise<number>
// Usage
const total = await Sum([1, 2, 3, 4, 5]) // 15
// Go
func GetConfig() map[string]interface{} {
return map[string]interface{}{
"theme": "dark",
"fontSize": 14,
"enabled": true,
}
}
// TypeScript
function GetConfig(): Promise<Record<string, any>>
// Usage
const config = await GetConfig()
console.log(config.theme) // "dark"
// Go
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func GetUser(id int) (*User, error) {
return &User{
ID: id,
Name: "Alice",
Email: "alice@example.com",
}, nil
}
// TypeScript (auto-generated)
interface User {
id: number
name: string
email: string
}
function GetUser(id: number): Promise<User>
// Usage
const user = await GetUser(1)
console.log(user.name) // "Alice"

JSON tags: Use json: tags to control field names in TypeScript.

// Go
func GetTimestamp() time.Time {
return time.Now()
}
// TypeScript
function GetTimestamp(): Promise<Date>
// Usage
const timestamp = await GetTimestamp()
console.log(timestamp.toISOString())
// Go
func Validate(input string) error {
if input == "" {
return errors.New("input cannot be empty")
}
return nil
}
// TypeScript
function Validate(input: string): Promise<void>
// Usage
try {
await Validate("")
} catch (error) {
console.error(error) // "input cannot be empty"
}

These types cannot be passed across the bridge:

  • Channels (chan T)
  • Functions (func())
  • Interfaces (except interface{} / any)
  • Pointers (except to structs)
  • Unexported fields (lowercase)

Workaround: Use IDs or handles:

// ❌ Can't pass file handle
func OpenFile(path string) (*os.File, error) {
return os.Open(path)
}
// ✅ Return file ID instead
var files = make(map[string]*os.File)
func OpenFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
id := generateID()
files[id] = file
return id, nil
}
func ReadFile(id string) ([]byte, error) {
file := files[id]
return io.ReadAll(file)
}
func CloseFile(id string) error {
file := files[id]
delete(files, id)
return file.Close()
}

Services can access the call context:

type UserService struct{}
func (s *UserService) GetCurrentUser(ctx context.Context) (*User, error) {
// Access window that made the call
window := application.ContextWindow(ctx)
// Access application
app := application.ContextApplication(ctx)
// Your logic
return getCurrentUser(), nil
}

Context provides:

  • Window that made the call
  • Application instance
  • Request metadata

For large data, use events instead of return values:

func ProcessLargeFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
// Emit progress events
app.Event.Emit("file-progress", map[string]interface{}{
"line": lineNum,
"text": scanner.Text(),
})
}
return scanner.Err()
}
import { Events } from '@wailsio/runtime'
import { ProcessLargeFile } from './bindings/FileService'
// Listen for progress
Events.On('file-progress', (data) => {
console.log(`Line ${data.line}: ${data.text}`)
})
// Start processing
await ProcessLargeFile('/path/to/large/file.txt')

Use context for cancellable operations:

func LongRunningTask(ctx context.Context) error {
for i := 0; i < 1000; i++ {
// Check if cancelled
select {
case <-ctx.Done():
return ctx.Err()
default:
// Continue work
time.Sleep(100 * time.Millisecond)
}
}
return nil
}

Note: Context cancellation on frontend disconnect is automatic.

Reduce bridge overhead by batching:

// ❌ Inefficient: N bridge calls
for _, item := range items {
await ProcessItem(item)
}
// ✅ Efficient: 1 bridge call
await ProcessItems(items)
func ProcessItems(items []Item) ([]Result, error) {
results := make([]Result, len(items))
for i, item := range items {
results[i] = processItem(item)
}
return results, nil
}
app := application.New(application.Options{
Name: "My App",
Logger: application.NewDefaultLogger(),
LogLevel: logger.DEBUG,
})

Output shows:

  • Method calls
  • Parameters
  • Return values
  • Errors
  • Timing information

Check frontend/bindings/ to see generated TypeScript:

frontend/bindings/MyService.ts
export function MyMethod(arg: string): Promise<number> {
return window.wails.Call('MyService.MyMethod', arg)
}

Test Go services without the frontend:

func TestGreetService(t *testing.T) {
service := &GreetService{prefix: "Hello, "}
result := service.Greet("Test")
if result != "Hello, Test!" {
t.Errorf("Expected 'Hello, Test!', got '%s'", result)
}
}
  • Batch operations - Reduce bridge calls
  • Use events for streaming - Don’t return large arrays
  • Keep methods fast - <100ms ideal
  • Use goroutines - For long operations
  • Cache on Go side - Avoid repeated calculations
  • Don’t make excessive calls - Batch when possible
  • Don’t return huge data - Use pagination or streaming
  • Don’t block - Use goroutines for long operations
  • Don’t pass complex types - Keep it simple
  • Don’t ignore errors - Always handle them

The bridge is secure by default:

  1. Whitelist only - Only registered services callable
  2. Type validation - Arguments checked against Go types
  3. No eval() - Frontend can’t execute arbitrary Go code
  4. No reflection abuse - Only exported methods accessible

Best practices:

  • Validate input in Go (don’t trust frontend)
  • Use context for authentication/authorisation
  • Rate limit expensive operations
  • Sanitise file paths and user input

Build System - Learn how Wails builds and bundles your application
Learn More →

Services - Deep dive into the service system
Learn More →

Events - Use events for pub/sub communication
Learn More →


Questions about the bridge? Ask in Discord or check the binding examples.