The browser blocks your request, but your server says it worked
You spin up a Go API on port 8080. You build a frontend on port 3000. You fetch an endpoint from JavaScript. The browser console flashes a red error about cross-origin requests. You check your Go server logs. The request returned a clean 200 OK. The data is there. The server did its job. The browser simply refuses to hand the response over to your code.
This disconnect is Cross-Origin Resource Sharing, or CORS. It is not a server configuration problem. It is a browser security policy. The server only needs to speak the right headers. The browser reads those headers and decides whether to trust the response.
CORS lives in the browser, not your server
Browsers enforce the same-origin policy by default. A page loaded from http://localhost:3000 cannot read responses from http://localhost:8080 unless the server explicitly grants permission. The browser acts as a gatekeeper between the network and your JavaScript runtime.
Think of it like a mailroom. The server is a warehouse that ships packages. The browser is the delivery driver. The warehouse can print any label it wants, but the driver only drops the package at the customer's door if the label matches the building's access rules. If the label is missing or wrong, the driver keeps the package in the van and tells the customer it never arrived. The warehouse still shipped it. The driver just refused to deliver it.
CORS is the label. The browser checks a few specific headers before allowing JavaScript to access the response body. If the headers are absent or mismatched, the browser blocks the response at the network layer. Your Go code never sees the block. It only sees that it sent a response.
The absolute minimum to make it work
Go's standard library does not include a CORS package. You set the headers yourself. Every cross-origin request needs at least two things: an origin header that matches the caller, and a method header that lists allowed verbs. Browsers also send a preflight OPTIONS request before most POST or PUT calls. You must answer that preflight with the same headers and a 204 status.
Here is the simplest handler that satisfies a basic cross-origin fetch:
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
// Tell the browser which origin is allowed to read this response
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
// List the HTTP methods the browser is allowed to use
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
// Declare which custom headers the browser may send
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// Answer preflight requests immediately without touching the actual handler
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Return the actual payload for GET and POST
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
http.ListenAndServe(":8080", mux)
}
The browser sends an OPTIONS request first. Your handler sees the method, writes a 204, and returns. The browser reads the Access-Control-Allow-* headers, caches them, and then sends the real GET or POST. Your handler runs again, skips the OPTIONS branch, and writes the JSON. The browser checks the origin header a second time, sees it matches, and finally passes the body to fetch().
Goroutines are cheap. Headers are not magic.
Building a reusable CORS wrapper
Hardcoding headers in every handler quickly becomes repetitive. Go solves this with the http.Handler interface. Any type that implements ServeHTTP(http.ResponseWriter, *http.Request) can wrap another handler. You can chain them like middleware.
The community convention is to wrap handlers rather than mutate global state. You create a struct that holds the allowed origin, attach a ServeHTTP method, and call the next handler only after setting the headers. This keeps your routes clean and makes testing trivial.
Here is a reusable wrapper that handles preflight and sets the correct headers:
package main
import (
"net/http"
)
// CORSHandler wraps an http.Handler and injects cross-origin headers.
type CORSHandler struct {
Origin string
Next http.Handler
}
// ServeHTTP sets CORS headers, handles preflight, then delegates to the next handler.
func (h CORSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Set the allowed origin so the browser knows who can read the response
w.Header().Set("Access-Control-Allow-Origin", h.Origin)
// Declare which methods the frontend may use
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
// Allow the frontend to send standard headers like Content-Type
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Short-circuit preflight requests so the wrapped handler never runs
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Pass control to the actual route handler
h.Next.ServeHTTP(w, r)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
// Wrap the entire mux so every route inherits the same CORS policy
handler := CORSHandler{Origin: "http://localhost:3000", Next: mux}
http.ListenAndServe(":8080", handler)
}
The wrapper runs before your route logic. It sets the headers once. It intercepts OPTIONS. It delegates everything else. You can attach it to a single route, a group of routes, or the entire ServeMux. The pattern scales because it relies on Go's built-in handler composition.
Trust the standard library. Wrap the handler, not the response.
Where things go wrong
CORS failures usually come from three mistakes. The first is using a wildcard origin with credentials. Browsers will reject Access-Control-Allow-Origin: * if your JavaScript sets credentials: "include". The browser requires an exact origin match when cookies or authorization headers are involved. Set the header to the exact Origin value from the request, or hardcode the allowed domain.
The second mistake is setting headers after writing the body. HTTP headers must be sent before the first byte of the response. If you call w.Write() or w.WriteHeader() before w.Header().Set(), the headers are already flushed. The browser receives a response without CORS headers and blocks it. The compiler will not catch this. You will only see it in the network tab.
The third mistake is forgetting that preflight caching is controlled by the server. Browsers cache preflight responses to avoid sending OPTIONS on every request. The cache duration comes from Access-Control-Max-Age. If you omit it, browsers use a conservative default, usually 5 seconds. Add the header to reduce preflight traffic during development or high-frequency polling.
// Cache preflight results for 10 minutes to reduce OPTIONS traffic
w.Header().Set("Access-Control-Max-Age", "600")
If you accidentally call w.WriteHeader() twice, the runtime panics with http: superfluous response.WriteHeader call. If you forget to handle OPTIONS on a route that requires it, the browser sees a 405 Method Not Allowed and aborts the fetch. Both errors appear in your terminal or logs, not in the browser console.
The worst CORS bug is the one that returns 200 but delivers an empty body to JavaScript.
Picking the right approach
CORS is a policy layer, not a business logic layer. Choose the implementation that matches your deployment shape and route count.
Use manual header setting when you only have one or two endpoints that need cross-origin access. Hardcoding the headers in a single handler keeps the code visible and avoids abstraction overhead.
Use a middleware wrapper when every endpoint in your service shares the same origin policy. Wrapping the ServeMux or individual routes keeps your handlers focused on data and logic instead of header boilerplate.
Use a third-party package like github.com/rs/cors when you need dynamic origin validation, per-route configuration, or automatic preflight caching. External packages handle edge cases like credential validation and header normalization so you do not have to maintain them.
Use a reverse proxy like Nginx, Caddy, or a cloud load balancer when CORS is a deployment concern rather than an application concern. Offloading header injection to the edge keeps your Go binary simple and lets you change policies without redeploying code.
Context is plumbing. Run it through every long-lived call site. CORS is a header. Set it once, set it early, and let the browser do the rest.