Table of Contents [expand]
Last updated March 20, 2026
This tutorial gets you started with a Go application that uses a WebSocket, deployed to Heroku.
Sample code for the demo application is available on GitHub. Edits and enhancements are welcome. Just fork the repository, make your changes and send us a pull request.
Prerequisites
- Go, Git, and the Heroku CLI (as described in Getting Started with Go).
- A Heroku user account. Signup is free and instant.
Create WebSocket app
The sample application provides a simple example of using WebSockets with Go. Get the sample app and follow along with the code as you read.
Get the sample app
$ git clone https://github.com/heroku-examples/go-websocket-chat-demo.git
$ cd go-websocket-chat-demo
Functionality
The sample application is a simple chat application that will open a WebSocket to the back-end. Any time a chat message is sent from the browser, it’s sent to the server and then published to a Redis channel. A separate goroutine is subscribed to the same Redis channel and broadcasts the received message to all open WebSocket connections.
There are a few key pieces to this example:
- The Gorilla WebSocket library which provides a complete WebSocket implementation.
- The go-redis library, which provides a Redis client with support for TLS connections required by Heroku Redis.
- Go’s standard library
log/slogpackage for structured logging. - JavaScript on the browser that opens a WebSocket connection to the server and responds to a WebSocket message received from the server.
Let’s take a look at both the back-end and front-end pieces in more detail.
Back-end
With Gorilla’s WebSocket library, we can create WebSocket handlers, much like standard http.Handlers. In this case, we have one endpoint that handles sending and receiving messages named handleWebSocket. A websocket.Upgrader is required to upgrade incoming requests to WebSocket connections. The endpoint is used for both submitting new messages to and receiving messages from the chat service. Incoming messages are received via ws.ReadMessage() as Go byte slices. We take those messages, validate them, and publish them to a Redis channel, so all connected servers can receive updates.
func handleWebSocket(hub *Hub, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("websocket upgrade failed", "err", err)
return
}
defer ws.Close()
hub.Register(ws)
defer hub.Deregister(ws)
for {
mt, data, err := ws.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) || err == io.EOF {
logger.Info("websocket closed")
} else {
logger.Error("error reading websocket message", "err", err)
}
return
}
if mt == websocket.TextMessage {
if _, err := validateMessage(data); err != nil {
logger.Error("invalid message", "err", err)
continue
}
if err := hub.Publish(r.Context(), data); err != nil {
logger.Error("failed to publish message", "err", err)
}
}
}
}
}
Messages received from Redis are broadcast to all WebSocket connections by the Hub‘s Subscribe() method.
func (h *Hub) Subscribe(ctx context.Context) error {
sub := h.rdb.Subscribe(ctx, channel)
defer sub.Close()
h.logger.Info("subscribed to redis channel", "channel", channel)
ch := sub.Channel()
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg, ok := <-ch:
if !ok {
return fmt.Errorf("redis subscription channel closed")
}
data := []byte(msg.Payload)
h.logger.Info("redis message received", "data", msg.Payload)
if _, err := validateMessage(data); err != nil {
h.logger.Error("invalid message from redis", "err", err)
continue
}
h.Broadcast(data)
}
}
}
Because of this architectural model, you can run this application on as many dynos as you want and all of your users will be able to send and receive updates.
In the main() func, we create a Redis client and a Hub that manages all WebSocket connections and Redis pub/sub. We register the handleWebSocket handler at /ws. The public directory is served by http.FileServer out of public/. We take special care to handle availability of the Redis server, which may not be available on boot and may be restarted as part of maintenance. The app also supports graceful shutdown via OS signals.
func main() {
logger := slog.Default()
port := os.Getenv("PORT")
if port == "" {
logger.Error("$PORT must be set")
os.Exit(1)
}
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
logger.Error("$REDIS_URL must be set")
os.Exit(1)
}
rdb, err := newRedisClient(redisURL)
if err != nil {
logger.Error("failed to create redis client", "err", err)
os.Exit(1)
}
hub := NewHub(rdb, logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
for {
if err := waitForRedis(ctx, rdb, logger, func() {
hub.Broadcast(waitingMsg)
}); err != nil {
return
}
hub.Broadcast(availableMsg)
if err := hub.Subscribe(ctx); err != nil {
if ctx.Err() != nil {
return
}
logger.Error("redis subscription error, reconnecting", "err", err)
continue
}
return
}
}()
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.Dir("./public")))
mux.HandleFunc("/ws", handleWebSocket(hub, logger))
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
}
go func() {
logger.Info("server starting", "port", port)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
rdb.Close()
}
Front-end
The second part of this is setting up the client side to open the WebSockets connection with the server.
The index page uses Bootstrap for CSS and
jQuery. We store all of our static assets in the public folder.
The main WebSocket interaction happens in public/js/application.js, which is
loaded by the main page. It opens our WebSocket connection to the server.
We use reconnecting-websocket,
which automatically reconnects any broken connection in the browser.
With an open WebSocket, the browser receives messages and we define a function to handle this process.
box.onmessage = function(message) {
var data = JSON.parse(message.data);
$("#chat-text").append("<div class='panel panel-default'><div class='panel-heading'>" + $('<span/>').text(data.handle).html() + "</div><div class='panel-body'>" + $('<span/>').text(data.text).html() + "</div></div>");
$("#chat-text").stop().animate({
scrollTop: $('#chat-text')[0].scrollHeight
}, 800);
};
The messages we’re using will be a JSON response with two keys: handle (user’s handle) and text (user’s message). When the message is received, it is parsed by JSON and inserted as a new entry in the page.
We override how the submit button works in our input form by using event.preventDefault() to stop the form from actually sending a POST. Instead, we grab the the values from the form and send them as a JSON message over the WebSocket to the server.
$("#input-form").on("submit", function(event) {
event.preventDefault();
var handle = $("#input-handle")[0].value;
var text = $("#input-text")[0].value;
box.send(JSON.stringify({ handle: handle, text: text }));
$("#input-text")[0].value = "";
});
Run locally
The sample application already contains a Procfile, which declares the web process:
web: go-websocket-chat-demo
You can run the app locally using Docker Compose:
$ docker compose up --build
This command starts both the app (on port 5000) and a local Redis instance. Open http://localhost:5000 in your browser to try it out.
Alternatively, if you have Go and Redis installed locally, you can build and run directly:
$ go build -o go-websocket-chat-demo .
$ cp .env.local .env
Modify the .env file as necessary, then launch with the Heroku CLI:
$ heroku local
Deploy
It’s time to deploy your app to Heroku. Create a Heroku app to deploy to:
$ heroku create
Creating app... done, ⬢ radiant-harbor-77012
https://radiant-harbor-77012-9082557b1993.herokuapp.com/ | https://git.heroku.com/radiant-harbor-77012.git
Add a Redis add-on:
$ heroku addons:create heroku-redis
Creating heroku-redis on radiant-harbor-77012... ~$0.004/hour (max $3/month)
Your add-on should be available in a few minutes.
redis-globular-22902 is being created in the background. The app will restart when complete...
Use heroku addons:info redis-globular-22902 to check creation progress
Use heroku addons:docs heroku-redis to view documentation
Deploy your code with git push.
$ git push heroku master
remote: -----> Building on the Heroku-24 stack
remote: -----> Determining which buildpack to use for this app
remote: -----> Go app detected
remote: -----> Detected go modules via go.mod
remote: -----> Detected Module Name: github.com/heroku-examples/go-websocket-chat-demo
remote: -----> Installing go1.25.8
remote: -----> Fetching go1.25.8.linux-amd64.tar.gz
remote: -----> Determining packages to install
remote: -----> Detected the following main packages to install:
remote: github.com/heroku-examples/go-websocket-chat-demo
remote: -----> Running: go install -v -tags heroku github.com/heroku-examples/go-websocket-chat-demo
remote: -----> Installed the following binaries:
remote: ./bin/go-websocket-chat-demo
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing...
remote: Done: 5.3M
remote: -----> Launching...
remote: Released v3
remote: https://radiant-harbor-77012-9082557b1993.herokuapp.com/ deployed to Heroku
Congratulations! Your web app should now be up and running on Heroku.
Next steps
Now that you have deployed your app and understand the basics we’ll want to expand the app to be a bit more robust. This won’t encompass everything you’d want to do before going public, but it will get you along the a path where you’ll be thinking about the right things.
Security
Remember, this is only a demo application and is likely vulnerable to various attacks. Please refer to WebSockets Security for more general guidelines on securing your WebSocket application.