Live Reload — Middle¶
1. The .air.toml you will actually edit¶
air init writes a default file. The interesting knobs:
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/server ./cmd/server"
bin = "./tmp/server"
full_bin = "APP_ENV=dev ./tmp/server --port 8080"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["assets", "tmp", "vendor", "node_modules", ".git"]
include_dir = ["internal", "cmd", "pkg"]
exclude_regex = ["_test\\.go$"]
delay = 200 # debounce window in ms
stop_on_error = true
kill_delay = "500ms" # grace period between SIGINT and SIGKILL
[log]
time = true
[misc]
clean_on_exit = true
Things to know: - cmd is your build step. It is just a shell command, so you can run templ generate && go build ... if you have a code-gen step. - bin is what gets executed unless full_bin is set; full_bin lets you forward env vars and CLI flags. - include_dir / exclude_dir are evaluated relative to root. - delay is the debounce: many editors save with multiple FS events (write + rename + chmod) and you do not want to rebuild three times.
2. Building only what you ship¶
For a service in cmd/api, narrow the build to that package and the watched paths to its dependencies:
[build]
cmd = "go build -o ./tmp/api ./cmd/api"
bin = "./tmp/api"
include_dir = ["cmd/api", "internal"]
exclude_dir = ["docs", "scripts", "deploy", "web/node_modules"]
Watching everything under . is the most common cause of thrash (rebuild loops) and latency (slow rebuilds). Less surface area = faster, more predictable reloads.
3. Templates, templ, htmx, and SPA front-ends¶
A common Go web stack looks like: templ (or html/template) for HTML + htmx on the client + a Vite/webpack pipeline for CSS/JS. There are two reload paths to coordinate:
- Server reload — rebuild Go on
.gochanges. - Asset/template reload — refresh the browser when
.templ,.html,.css, or.jschanges.
Two clean approaches:
# A) templates are baked into the binary — rebuild on template changes too
include_ext = ["go", "templ", "html", "tmpl", "css", "js"]
cmd = "templ generate && go build -o ./tmp/web ./cmd/web"
# B) templates are read from disk at request time — no rebuild needed
include_ext = ["go"]
# run a separate watcher (entr, browser-sync) for templates/assets
Pattern A is simpler but every CSS tweak triggers a Go rebuild. Pattern B is faster but needs a second watcher and templates loaded from disk.
For SPAs, run the front-end dev server (vite dev) in its own terminal and let air worry only about Go.
4. Restart latency vs build latency¶
Total reload time = debounce + build + process start + readiness.
| Stage | Typical cost | What you can do |
|---|---|---|
| Debounce | 100–300ms | Tune delay; lower = snappier, higher = fewer duplicate builds |
go build | 100ms–several s | Narrow packages, warm GOCACHE, CGO_ENABLED=0, -buildvcs=false |
| Process start | 10–200ms | Lazy-init expensive things; reduce init() work |
| Readiness | depends | Make the HTTP listener bind early; warm caches after ListenAndServe |
If a developer's reload feels slow, profile which stage owns the time before tuning blindly.
5. Forwarding environment variables¶
air runs your binary as a subprocess and inherits your shell environment by default. To set env vars only for the watched process, use full_bin:
Or load from a .env file before launching air:
A subtle point: env vars referenced inside cmd are evaluated at build time, not at run time. Use full_bin for run-time env, cmd for build-time env (CGO_ENABLED=0, GOFLAGS).
6. The port-reuse pitfall (graceful shutdown)¶
The most common live-reload bug:
air sends SIGINT (then SIGKILL after kill_delay) to the old process and immediately launches the new one. If the old process did not actually close its listener, the new one cannot bind the port. You must implement graceful shutdown:
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
http.Server.Shutdown closes the listener and waits for in-flight requests. Pair this with kill_delay = "2s" in .air.toml so air waits long enough for the shutdown to finish before SIGKILLing.
On Linux you can also enable SO_REUSEADDR/SO_REUSEPORT via a custom net.ListenConfig so the new process can bind before the old socket's TIME_WAIT clears — useful but not a substitute for graceful shutdown.
7. Watching only the right things¶
Defaults watch too broadly. Tighten them:
exclude_dir = ["assets", "tmp", "vendor", "node_modules", ".git", "dist", "build", ".idea", ".vscode"]
exclude_regex = ["_test\\.go$", "_gen\\.go$", "\\.pb\\.go$"]
exclude_file = ["openapi.yaml"]
follow_symlink = false
Watching test files causes rebuilds when you only adjusted a _test.go. Excluding generated files (*_gen.go, *.pb.go) prevents loops where generation re-triggers the watcher.
8. Running air from a subdirectory¶
air runs from the directory where you invoke it. Two patterns:
# A) one .air.toml per service, invoke air from that service's dir
cd cmd/api && air
# B) one .air.toml at the repo root, point cmd at the service
air -c .air.api.toml
Pattern B works well in a monorepo when paired with air -c <file> per service. Avoid running two air instances against the same binary path or temp dir — they will fight.
9. Trade-offs summary¶
| Concern | go run loop | air-style live reload |
|---|---|---|
| Setup | None | Config file |
| Restart on save | Manual | Automatic |
| Build cost | Same go build cost | Same + debounce + process restart |
| State preserved | No (you killed it) | No (full restart) |
| Production-safe | No | No, ever |
| Multi-service repo | Painful | Multiple air configs |
10. Summary¶
Edit your .air.toml to narrow what is watched and what is built, route env vars through full_bin, and pair it with http.Server.Shutdown plus a generous kill_delay so port-reuse errors never appear. Coordinate template/asset reloads explicitly: bake them and rebuild, or load from disk and run a separate watcher. The goal is a sub-second edit → reload cycle, and most slowness comes from watching too much or shutting down too slowly.
Further reading¶
airconfig reference: https://github.com/air-verse/air/blob/master/air_example.tomlhttp.Server.Shutdown: https://pkg.go.dev/net/http#Server.Shutdowntempl: https://templ.guide/