# Go Bluetooth: one BLE API across Linux, macOS, Windows, the browser, and bare metal

> tinygo.org/x/bluetooth is a cross-platform Go package for Bluetooth Low Energy that runs against BlueZ, CoreBluetooth, WinRT, WebBluetooth, Nordic SoftDevice, and raw HCI controllers, so the same central or peripheral code can be compiled for a laptop or flashed onto a microcontroller.

**tinygo-org/bluetooth** — Cross-platform Bluetooth API for Go and TinyGo. Supports Linux, macOS, Windows, & bare metal using Nordic SoftDevice or HCI (ESP32/Pico-W/etc)

- Repository: https://github.com/tinygo-org/bluetooth
- Website: https://tinygo.org
- Stars: 1,020 · Forks: 199
- Language: C
- License: NOASSERTION
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/tinygo-org-bluetooth

## One API, eight backends, and the reason that matters

Go Bluetooth is a cross-platform package for using Bluetooth Low Energy hardware from the Go programming language. It works on typical operating systems such as Linux, macOS, and Windows. It also works in the browser with WASM.

The second half of the sentence is the more interesting one. By using TinyGo, it can also be used running bare metal on microcontrollers produced by Nordic Semiconductor, on the Espressif ESP32-C3 and ESP32-S3 with their onboard radio, or on boards that have a Bluetooth co-processor that uses the Bluetooth Host Controller Interface.

So the package can create both Bluetooth Low Energy Centrals and Bluetooth Low Energy Peripherals, and the same source can be a scanner on a laptop and an advertising sensor on a microcontroller. The support table maps each of the eight targets to the API it uses, and the mapping is the interesting artefact: BlueZ on Linux, CoreBluetooth on macOS, WinRT on Windows, WebBluetooth in the browser, SoftDevice on Nordic parts, and HCI on the three embedded targets, which are the ESP32 with the NINA firmware, the CYW43439 on the RP2040-W, and the ESP32-C3 and S3 through espradio.

The primitive language is the same everywhere, which is the whole point. You call `adapter.Enable()`, `adapter.Scan`, `adapter.SetConnectHandler`, and `adapter.DefaultAdvertisement()`, and the implementation underneath is whichever native stack the target has.

The package is `tinygo.org/x/bluetooth`, it is published on pkg.go.dev under that path, the homepage is tinygo.org, and the default branch is `dev`.

The licence field is recorded as NOASSERTION, which is what a repository listing shows when it cannot classify the licence automatically, and the LICENSE file in the tree is the place to look.

## The central example, which is also the whole API tour

A typical Bluetooth Low Energy Central is your laptop or phone, and the example shows a central that scans for peripherals and prints them as they are found.

```go
package main

import (
	"tinygo.org/x/bluetooth"
)

var adapter = bluetooth.DefaultAdapter

func main() {
	// Enable BLE interface.
	must("enable BLE stack", adapter.Enable())

	// Start scanning.
	println("scanning...")
	err := adapter.Scan(func(adapter *bluetooth.Adapter, device bluetooth.ScanResult) {
		println("found device:", device.Address.String(), device.RSSI, device.LocalName())
	})
	must("start scan", err)
}

func must(action string, err error) {
	if err != nil {
		panic("failed to " + action + ": " + err.Error())
	}
}
```

Four things to notice. There is a package-level `bluetooth.DefaultAdapter` rather than a constructor, so the same adapter object is the entry point everywhere. `Enable()` comes first and can fail, because on several platforms the radio needs to be powered up and permitted before anything else works. `Scan` takes a callback rather than returning a channel, and the callback receives a `bluetooth.ScanResult` that gives you the address, the RSSI, and the local name. And the `must` helper is the whole error handling story, which is a good sign about the error type, since every failure in the package is expected to be an error value rather than a panic.

The scan callback form is a deliberate API choice. A channel of results would be the more Go-ish shape in some readings, but a callback keeps the call on the stack of the caller, which on a microcontroller with a few kilobytes of stack is a real consideration.

The `Scan` call blocks, which is why the example never reaches the `must` on its error until the scan ends. That is a pattern to be aware of when you write your own scanning loop.

## The peripheral example, and the resource handling it shows

A typical Bluetooth Low Energy Peripheral is a temperature sensor or heart rate sensor, and the example shows one advertising itself as available for connection.

```go
package main

import (
	"context"
	"time"

	"tinygo.org/x/bluetooth"
)

var adapter = bluetooth.DefaultAdapter

func main() {
  	// Enable BLE interface.
	must("enable BLE stack", adapter.Enable())

	ctx, cancel := context.WithCancel(context.Background())
	adapter.SetConnectHandler(func(device bluetooth.Device, connected bool) {
		if connected {
			println("device connected:", device.Address.String())
			return
		}

		println("device disconnected:", device.Address.String())
		cancel()
	})

  	// Define the peripheral device info.
	adv := adapter.DefaultAdvertisement()
	must("config adv", adv.Configure(bluetooth.AdvertisementOptions{
		LocalName: "Go Bluetooth",
  	}))
  
  	// Start advertising
	must("start adv", adv.Start())
	
	// Stop advertising to release resources
	defer adv.Stop()

	println("advertising...")
	<- ctx.Done()
}

func must(action string, err error) {
	if err != nil {
		panic("failed to " + action + ": " + err.Error())
	}
}
```

The shape here is a lifecycle: enable the stack, register a connect handler, configure and start the advertisement, wait, and stop the advertisement to release resources.

The `defer adv.Stop()` with its comment is the detail worth noticing. On a microcontroller the advertisement occupies radio time and memory, so leaving it running after the program is done with it is a resource leak rather than a style question, and the API makes the stop explicit.

The `context.WithCancel` plus the handler cancelling on disconnect is the other half. The context gives the main goroutine a single place to block, and the cancellation is triggered by the disconnect event, so the program ends when the peer goes away. That is a good pattern for a sensor that advertises, connects once, and exits.

`AdvertisementOptions` with a `LocalName` is the only configuration in this example, but the option struct is clearly the extension point, and the shape mirrors the JSON Web Bluetooth advertisement data, so a device name, a list of service UUIDs, and manufacturer data all have somewhere to go.

## The support table, and the two columns with gaps

The support table has eight capability rows against the eight targets, and the two columns with crosses are the ones to read.

Scanning is supported everywhere. Connect to peripheral is supported everywhere. Write peripheral characteristics is supported everywhere. Receive notifications is supported everywhere.

Advertisement is supported everywhere except macOS and WASM. Local services is supported everywhere except macOS and WASM. Local characteristics is supported everywhere except macOS and WASM. And send notifications is supported everywhere except macOS and WASM.

So the split is clean and it is exactly what you would expect from the underlying stacks. On macOS, CoreBluetooth gives an application control over scanning and over a client connection, but a macOS app publishing a GATT server is a different and much more restricted model. WebBluetooth has the same shape, with the browser granting a page access to a peripheral rather than letting it publish one.

This is the single most important thing to check before you design a system around this library, and it is easy to miss, because the summary sentence that opens the package says it can create both centrals and peripherals without qualification. It can, but not on macOS and not in the browser, and on those two you can only be the central.

The rest of the table is uniform, which is impressive for eight quite different stacks. The two embedded HCI targets, the ESP32 with NINA firmware and the CYW43439 on the RP2040-W, behave the same as the desktop platforms in every row, and the ESP32-C3 and ESP32-S3 through espradio do too.

The consequence for a project is that a development loop on a laptop and a deployment on a microcontroller have the same API surface, but not the same capabilities if the laptop is a Mac. If you develop the peripheral half on macOS, you will find that it works there and does not work on the device, and the support table is where that is written down.

## How the code is organised, and the sheer number of adapters

The top level of the repository is a long list of adapter files, and reading the names tells you the shape of the problem.

There is one `adapter_` file per platform or board family. `adapter_darwin.go` for macOS, `adapter_windows.go`, `adapter_linux.go`, and `adapter_js.go` for the browser. `adapter_sd.go` for Nordic SoftDevice, with `adapter_nrf51.go`, `adapter_nrf528xx.go`, `adapter_nrf528xx-full.go`, and `adapter_nrf528xx-peripheral.go` for the Nordic families, and `adapter_ninafw.go` for the ESP32 with the NINA firmware. Then `adapter_espradio.go` for the ESP32-C3 and S3 through espradio, and `adapter_cyw43439.go` for the CYW43439 co-processor.

And then the ones that show how much variation there is under the same idea. `adapter_hci.go` and `adapter_hci_uart.go` are the generic HCI controller paths, one for USB and one for a serial connection. `adapter_usbvbus-nrf52840.go` and `adapter_usbvbus-other.go` are the USB vendor specific path, split by which radio you have. `adapter_dcsupply-other.go` handles the DC supply variant of the Nordic parts, and the SoftDevice family has a file per silicon revision, `adapter_s110.go`, `adapter_s113v7.go`, `adapter_s132.go`, `adapter_s140v6.go`, and `adapter_s140v7.go`.

The GAP layer follows the same convention with `gap_` files, and there is a `gap_test.go`, which suggests the shared logic is tested against a common test harness rather than per platform.

The rest of the tree shows what a BLE stack needs beyond the radio. There is `ble.go` and a `ble/` directory, `gattc.go` for the client side of the attribute protocol, `gap.go` for the generic attribute profile, `characteristic_uuids.go`, `bluetooth.go`, and `bluetooth-numbers-database` as a directory, which is the assigned numbers registry. There is `flash_nrf528xx.go` and `flash_other.go` for programming the Nordic radios, and `bond_storage_nrf528xx.go` for pairing state. `errors_hci.go` and `error_sd.go` are per-backend error types, and `debug.go` is a logging helper.

The examples directory is the best guide to the API, with twenty of them: `advertisement/`, `battery/`, `beacon/`, `beacon-lowpower/`, `circuitplay/`, `connparams/`, `device-information/`, `discover/`, `flash/`, `heartrate/`, `heartrate-monitor/`, `ledcolor/`, `nusclient/`, `nusserver/`, `pairing-justworks/`, `pairing-passkey/`, `scanner/`, `stop-advertisement/`, `tinyscan/`, `usbvbus/`, and `webbluetooth/`.

## The build system compiles examples for real boards

The Makefile is the clearest statement of what the project is for, and it is a smoke test that builds every example for actual hardware targets.

The `smoketest` target chains four variants, `smoketest-tinygo`, `smoketest-linux`, `smoketest-windows`, and `smoketest-wasm`, so the same examples are checked against the embedded toolchain, the three desktop platforms, and the browser.

Each TinyGo build names a board and an example, and the pattern repeats:

```makefile
$(TINYGO) build -o test.hex -size=short -target=pca10040-s132v6       ./examples/advertisement
@md5sum test.hex
```

The targets are real boards, a Nordic PCA10040 with an S132 v6 SoftDevice part, a PCA10056 with an S140 v7, a micro:bit v2 with an S113 v7, a reelboard with an S140 v7, a nano RP2040, and a Circuit Playground Bluefruit which produces a UF2 rather than a hex. The `-size=short` flag is telling, because it is a size optimisation that trades debug information for a smaller binary, and the flash budget on a microcontroller is the binding constraint.

The `md5sum` after each build is a form of record keeping. It gives you a digest of the artefact, so a change in the output for the same source is visible, which on an embedded target where the binary is the deliverable is a useful check.

There are also comments in the Makefile explaining why a particular example is built for several targets. The `beacon-lowpower` example calls `EnableDCSupply` and `SetTxPower`, so it is built for each SoftDevice variant and for a board that uses the stubs, and the pairing examples link in the elliptic curve Diffie-Hellman code for LE.

The Go module is `tinygo.org/x/bluetooth` requiring Go 1.25.0, and the dependency list tells you the layering. `github.com/godbus/dbus/v5` is the D-Bus binding for the Linux BlueZ path, `github.com/saltosystems/winrt-go` is the Windows Runtime binding, `github.com/soypat/cyw43439` is the CYW43439 co-processor support, and `github.com/tinygo-org/cbgo` is the C bridge generator used to call into the vendor C stacks. The TinyGo ecosystem libraries for drivers, espradio, tinyfont, and tinyterm are there for the embedded examples, and `golang.org/x/crypto` is used by the pairing code.

## Conclusion

Go Bluetooth fits a team that wants to write one BLE program and run it on a laptop for development and on a microcontroller in production, and that accepts Go rather than C for the firmware side. It is the wrong choice for a Bluetooth classic application, for platforms outside the eight in the support table, and for a team that needs the peripheral half on macOS or in the browser, since those two are marked unsupported for advertising and local services. Check three things before you adopt it: that your target is in the support table and that the rows you need are ticked for it, that the HCI variant you plan to use has a supported board or controller, and that you read the TinyGo requirement, because the bare metal and ESP32 paths are TinyGo and not standard Go.

## FAQ

### What platforms does the Go Bluetooth package support?

Eight: Linux through BlueZ, macOS through CoreBluetooth, Windows through WinRT, the browser through WebBluetooth, Nordic parts through SoftDevice, the ESP32 with NINA firmware through HCI, the CYW43439 on the RP2040-W through HCI, and the ESP32-C3 and ESP32-S3 through espradio. Scanning, connecting, writing characteristics, and receiving notifications are supported on all of them.

### Can the Go Bluetooth package act as a BLE peripheral?

Yes, on Linux, Windows, and all three embedded targets. Advertisement, local services, local characteristics, and sending notifications are marked unsupported on macOS and on WASM, so on those two the package works only as a central.

### How do I write a BLE scanner in Go with this package?

Use `bluetooth.DefaultAdapter`, call `adapter.Enable()`, then `adapter.Scan` with a callback that receives a `bluetooth.ScanResult` giving the address, RSSI, and local name. The scan call blocks, so you need a separate goroutine or signal to stop it, and the `must` helper in the example shows the pattern for handling the error values the package returns.

### Does the Go Bluetooth package work with TinyGo on microcontrollers?

Yes. By using TinyGo it can run bare metal on Nordic Semiconductor microcontrollers, on the Espressif ESP32-C3 and ESP32-S3 with their onboard radio, or on boards with an HCI Bluetooth co-processor. The Makefile builds the examples for boards such as the PCA10040, PCA10056, micro:bit v2, reelboard, nano RP2040, and Circuit Playground Bluefruit.

## Sources

- [Issues](https://github.com/tinygo-org/bluetooth/issues)
- [Project website](https://tinygo.org)
- [README](https://github.com/tinygo-org/bluetooth/blob/dev/README.md)
- [Releases](https://github.com/tinygo-org/bluetooth/releases)
- [tinygo-org/bluetooth on GitHub](https://github.com/tinygo-org/bluetooth)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tinygo-org-bluetooth
