Skip to main content

Building Desktop Applications in Go with Fyne: Stock Market Dashboard

·7 mins
Table of Contents
Fyne is Go’s best cross-platform UI toolkit. One codebase compiles to Windows, macOS, and Linux, shipping as a native binary with no runtime dependency. This post builds a real stock market dashboard, covering Fyne’s widget system, layout engine, goroutine safety rules, and distribution.

Why Fyne
#

Go desktop applications have historically been awkward. CGo-based bindings to GTK or Qt work, but the build complexity is painful. Fyne takes a different approach: it uses OpenGL for rendering, keeping the API pure Go and the resulting binary fully self-contained.

The tradeoff is that Fyne apps look like Fyne apps rather than native apps. If pixel-perfect platform integration matters, look at Gio or webview-based approaches. If you want a cross-platform app that ships easily and looks consistent, Fyne is the right tool.

Setup
#

terminal
# CGo is required. On macOS this comes with Xcode Command Line Tools:
xcode-select --install

# On Ubuntu/Debian:
# sudo apt-get install gcc libgl1-mesa-dev xorg-dev

go mod init stock-dashboard
go get fyne.io/fyne/v2@latest
Important

Fyne requires CGo. If you see cgo: C compiler "gcc" not found, install the platform prerequisites listed above before running go get.

App and Window Basics
#

Every Fyne application starts with app.New(). The window is the top-level container.

main.go
package main

import (
    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/app"
    "fyne.io/fyne/v2/container"
    "fyne.io/fyne/v2/widget"
)

func main() {
    a := app.New()
    w := a.NewWindow("Stock Dashboard")
    w.Resize(fyne.NewSize(800, 600))

    w.SetContent(widget.NewLabel("Loading..."))
    w.ShowAndRun()
}

ShowAndRun blocks until the window is closed. Everything that happens in the application must be coordinated around this main loop.

Widgets
#

Fyne provides a set of standard widgets. The ones used in this dashboard:

widgets.go
// Text input
entry := widget.NewEntry()
entry.SetPlaceHolder("AAPL")

// Button with callback
button := widget.NewButton("Fetch", func() {
    // runs on main thread, safe to update UI here
})

// Read-only label
label := widget.NewLabel("Price: --")

// Dropdown selection
selector := widget.NewSelect(
    []string{"1D", "5D", "1M", "3M"},
    func(selected string) {
        // handle selection change
    },
)

// Progress indicator while fetching
progress := widget.NewProgressBarInfinite()
progress.Hide() // shown only during fetch

Layouts
#

Layouts determine how widgets are arranged inside containers. The most common layouts:

Stacks widgets vertically.

layout.go
content := container.NewVBox(
    widget.NewLabel("Stock Symbol"),
    entry,
    button,
    label,
)

Stacks widgets horizontally.

layout.go
searchBar := container.NewHBox(
    entry,
    button,
)

Places widgets at edges with a fill area in the center. Good for main layout.

layout.go
main := container.NewBorder(
    searchBar,  // top
    nil,        // bottom
    nil,        // left
    nil,        // right
    content,    // center fill
)

Fetching Stock Data from Alpha Vantage
#

Alpha Vantage has a free tier (25 requests per day) and a straightforward REST API. Sign up at alphavantage.co to get a key.

api.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

type DailyResponse struct {
    MetaData   map[string]string            `json:"Meta Data"`
    TimeSeries map[string]map[string]string `json:"Time Series (Daily)"`
}

type DayData struct {
    Date  string
    Close float64
}

func fetchDailyPrices(symbol, apiKey string) ([]DayData, error) {
    url := fmt.Sprintf(
        "https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=%s&outputsize=compact&apikey=%s",
        symbol, apiKey,
    )

    resp, err := http.Get(url)
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    var data DailyResponse
    if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
        return nil, fmt.Errorf("decode failed: %w", err)
    }

    // The API returns a map keyed by date string. We flatten it to a slice.
    var result []DayData
    for date, values := range data.TimeSeries {
        var close float64
        fmt.Sscanf(values["4. close"], "%f", &close)
        result = append(result, DayData{Date: date, Close: close})
    }

    // Sort by date descending
    sort.Slice(result, func(i, j int) bool {
        return result[i].Date > result[j].Date
    })

    return result, nil
}

Displaying Data: Label and Table
#

The price label updates after each fetch. The table shows the last 10 trading days.

display.go
func buildPriceTable(days []DayData) *widget.Table {
    table := widget.NewTable(
        func() (int, int) {
            return len(days), 2 // rows, columns
        },
        func() fyne.CanvasObject {
            return widget.NewLabel("")
        },
        func(id widget.TableCellID, obj fyne.CanvasObject) {
            label := obj.(*widget.Label)
            switch id.Col {
            case 0:
                label.SetText(days[id.Row].Date)
            case 1:
                label.SetText(fmt.Sprintf("%.2f", days[id.Row].Close))
            }
        },
    )
    table.SetColumnWidth(0, 120)
    table.SetColumnWidth(1, 100)
    return table
}

Drawing a Price Sparkline
#

Fyne’s canvas package provides primitives for drawing. We use canvas.Line to draw a sparkline chart by normalising prices to the widget bounds.

chart.go
package main

import (
    "image/color"

    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/canvas"
    "fyne.io/fyne/v2/container"
    "fyne.io/fyne/v2/widget"
)

type SparklineWidget struct {
    widget.BaseWidget
    prices []float64
}

func NewSparkline(prices []float64) *SparklineWidget {
    s := &SparklineWidget{prices: prices}
    s.ExtendBaseWidget(s)
    return s
}

func (s *SparklineWidget) CreateRenderer() fyne.WidgetRenderer {
    lines := make([]*canvas.Line, 0, len(s.prices)-1)
    for range s.prices[1:] {
        l := canvas.NewLine(color.RGBA{R: 0, G: 180, B: 120, A: 255})
        l.StrokeWidth = 2
        lines = append(lines, l)
    }
    return &sparklineRenderer{lines: lines, spark: s}
}

type sparklineRenderer struct {
    lines []*canvas.Line
    spark *SparklineWidget
}

func (r *sparklineRenderer) Layout(size fyne.Size) {
    if len(r.spark.prices) < 2 {
        return
    }
    min, max := r.spark.prices[0], r.spark.prices[0]
    for _, p := range r.spark.prices {
        if p < min { min = p }
        if p > max { max = p }
    }
    span := max - min
    if span == 0 { span = 1 }

    n := float32(len(r.spark.prices) - 1)
    for i, line := range r.lines {
        x1 := float32(i) / n * size.Width
        x2 := float32(i+1) / n * size.Width
        y1 := size.Height - float32((r.spark.prices[i]-min)/span)*size.Height
        y2 := size.Height - float32((r.spark.prices[i+1]-min)/span)*size.Height
        line.Position1 = fyne.NewPos(x1, y1)
        line.Position2 = fyne.NewPos(x2, y2)
    }
}

func (r *sparklineRenderer) MinSize() fyne.Size     { return fyne.NewSize(200, 60) }
func (r *sparklineRenderer) Refresh()               { canvas.Refresh(r.spark) }
func (r *sparklineRenderer) Destroy()               {}
func (r *sparklineRenderer) Objects() []fyne.CanvasObject {
    objs := make([]fyne.CanvasObject, len(r.lines))
    for i, l := range r.lines { objs[i] = l }
    return objs
}

Goroutines and UI Thread Safety
#

Fyne’s UI must only be updated from the main thread. If you call label.SetText() from a goroutine, you will get race conditions or a crash.

The pattern for safe async updates:

fetch.go
func onFetchClicked(symbol string, apiKey string, priceLabel *widget.Label, progress *widget.ProgressBarInfinite) {
    // Show spinner and disable button -- safe, we are on main thread here
    progress.Show()

    go func() {
        days, err := fetchDailyPrices(symbol, apiKey)

        // ALL UI updates must go back to main thread
        fyne.CurrentApp().Driver().CallOnMainThread(func() {
            progress.Hide()
            if err != nil {
                priceLabel.SetText("Error: " + err.Error())
                return
            }
            if len(days) > 0 {
                priceLabel.SetText(fmt.Sprintf("%.2f USD  (latest close)", days[0].Close))
            }
        })
    }()
}
Warning

Never update Fyne widgets directly from a goroutine. Use fyne.CurrentApp().Driver().CallOnMainThread() to marshal the update back to the main thread. Skipping this causes intermittent crashes that are hard to reproduce.

Putting It Together
#

main.go
func main() {
    apiKey := os.Getenv("ALPHAVANTAGE_API_KEY")
    if apiKey == "" {
        log.Fatal("set ALPHAVANTAGE_API_KEY environment variable")
    }

    a := app.New()
    w := a.NewWindow("Stock Dashboard")
    w.Resize(fyne.NewSize(800, 600))

    entry    := widget.NewEntry()
    entry.SetPlaceHolder("AAPL")

    priceLabel := widget.NewLabel("Price: --")
    progress   := widget.NewProgressBarInfinite()
    progress.Hide()

    var tableContainer *fyne.Container

    button := widget.NewButton("Fetch", func() {
        onFetchClicked(entry.Text, apiKey, priceLabel, progress)
    })

    searchBar := container.NewBorder(nil, nil, nil, button, entry)

    layout := container.NewVBox(
        searchBar,
        progress,
        priceLabel,
    )

    w.SetContent(container.NewBorder(layout, nil, nil, nil, widget.NewLabel("")))
    _ = tableContainer
    w.ShowAndRun()
}

Cross-Platform Distribution
#

terminal
# Install the Fyne CLI
go install fyne.io/tools/cmd/fyne@latest

# Package for the current platform
fyne package -name "Stock Dashboard" -appID com.example.stockdashboard

# Cross-compile (requires Docker or a matching toolchain)
fyne package -os windows -name "Stock Dashboard"
fyne package -os linux  -name "Stock Dashboard"

The fyne package command produces a .app bundle on macOS, a .exe with resources embedded on Windows, and a tarball on Linux. All dependencies are included in the output.

Updating UI directly from a goroutine
The most common mistake in Fyne apps. Any call to widget.SetText, widget.Show, widget.Hide or similar from a goroutine will cause a race condition. Always use fyne.CurrentApp().Driver().CallOnMainThread() to run UI updates on the main thread.
Hitting Alpha Vantage rate limits on the free tier
The free tier allows 25 API requests per day. If you fetch aggressively during development you will quickly exhaust the quota. Cache responses to disk during development, and add a check before making a network call.
No loading state
Without a spinner or disabled button, the user can click Fetch multiple times while a request is in flight, causing multiple concurrent goroutines all trying to update the same widgets. Show ProgressBarInfinite before launching the goroutine and hide it inside CallOnMainThread when done.
Hardcoding the API key
Never embed an API key in source code. Read it from an environment variable (os.Getenv) or a config file that is listed in .gitignore. Hardcoded keys end up in git history and are difficult to rotate.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related