Skip to main content

GraphQL Subscriptions with gqlgen

Github StackBlitz

This example shows a schema-first GraphQL server built with gqlgen on Fiber v3. It serves a query, a mutation and a subscription that fires on every mutation, all on one /query path.

The domain is a single message feed. postMessage stores a message, messages returns the most recent ones and messageAdded streams every new message to subscribed clients.

How It Works on Fiber​

Queries and mutations go through the Fiber adaptor middleware, which runs the gqlgen net/http handler on Fiber.

The adaptor cannot carry a websocket upgrade, so subscriptions use the Fiber websocket middleware on the same /query path, and a small bridge in graphqlws.go hands the upgraded connection to gqlgen's own websocket transport.

The protocol is graphql-transport-ws, which the graphql-ws library and GraphiQL speak. Upgrades that do not offer it, including legacy graphql-ws, get a 400 response.

Project Structure​

  • main.go: gqlgen server setup, routes and graceful shutdown.
  • graphqlws.go: bridge from the Fiber websocket connection to the gqlgen websocket transport.
  • complexity.go: the operation complexity limit.
  • chat/hub.go: in-memory message history and pub-sub fan-out.
  • graph/schema.graphqls: the GraphQL schema.
  • graph/resolver.go: the root resolver and the per-connection subscription limit.
  • graph/schema.resolvers.go: resolver implementations.

Requirements​

  • Go 1.26 or higher
  • Git
  • Optional: websocat to test subscriptions from the command line

Setup​

  1. Clone the repository:

    git clone https://github.com/gofiber/recipes.git
    cd recipes/gqlgen
  2. Run the application:

    go run .

The server listens on http://localhost:3000. GraphiQL opens at http://localhost:3000/.

Example Usage​

  1. Query the recent messages:

    curl -s http://localhost:3000/query -H 'Content-Type: application/json' -d '{"query":"{ messages { id author text createdAt } }"}'
    {"data":{"messages":[]}}
  2. In a second terminal, open a websocket session. The command sends connection_init at once, because the server closes a connection that sends nothing within 3 seconds. cat then passes what you type to the server and keeps the connection open:

    (printf '%s\n' '{"type":"connection_init"}'; cat) | websocat --protocol graphql-transport-ws ws://localhost:3000/query

    Wait for the acknowledgment:

    {"type":"connection_ack"}

    Then paste the subscription and press Enter:

    {"id":"1","type":"subscribe","payload":{"query":"subscription { messageAdded { id author text createdAt } }"}}
  3. Post a message:

    curl -s http://localhost:3000/query -H 'Content-Type: application/json' -d '{"query":"mutation($in: NewMessage!) { postMessage(input: $in) { id author text } }","variables":{"in":{"author":"ana","text":"hello"}}}'
    {"data":{"postMessage":{"id":"1","author":"ana","text":"hello"}}}

    The websocat terminal prints the event:

    {"payload":{"data":{"messageAdded":{"id":"1","author":"ana","text":"hello","createdAt":"2026-10-29T11:31:16.823051Z"}}},"id":"1","type":"next"}
  4. Type {"id":"1","type":"complete"} in the websocat terminal to stop the subscription. The server confirms with {"id":"1","type":"complete"}, and later posts no longer arrive. The server also sends {"type":"ping"} every 10 seconds.

  5. Invalid input returns a GraphQL error:

    curl -s http://localhost:3000/query -H 'Content-Type: application/json' -d '{"query":"mutation { postMessage(input: {author: \"ana\", text: \"\"}) { id } }"}'
    {"errors":[{"message":"text must be 1 to 280 characters","path":["postMessage"],"locations":[{"line":1,"column":12}]}],"data":null}

Regenerating Code​

  1. Edit graph/schema.graphqls.
  2. Run go generate ./... or make generate. The generator version comes from the tool line in go.mod.
  3. Implement any new resolver stubs in graph/schema.resolvers.go.
  4. Commit the generated files. make build, which CI runs, regenerates the code and fails if that changes any file in graph/.

Design Notes​

  • The history keeps the last 100 messages. messages(last: n) accepts 1 to 100.
  • Every subscriber has a buffer of 16 messages. A subscriber that falls further behind is evicted and receives complete, so a slow client never blocks postMessage.
  • The server accepts at most 10000 websocket connections, 10000 open subscriptions in total and 10 subscriptions per connection. Extra connections are closed with code 1013, try again later.
  • Every operation is limited to 1000 parser tokens and a complexity of 500, since gqlgen sets no cost limit by default. complexity.go computes the cost like extension.FixedComplexityLimit, but walks each named fragment only once. It does not handle interfaces or unions, which this schema does not use. Add them before you copy it into a schema that has them, or the score comes out too low.
  • MissingPongOk: true keeps clients that do not answer pings, such as websocat, connected. Set it to false in production so dead peers are dropped.
  • Websocket operations do not see the HTTP request headers. Authentication for subscriptions belongs in transport.Websocket.InitFunc, which reads the connection_init payload.
  • websocket.Config.Origins lists http://localhost:3000 and http://127.0.0.1:3000, so pages on other sites cannot open subscriptions. Replace them with your frontend's origin. An empty list would allow every origin. AllowEmptyOrigin: true keeps clients that send no Origin header, such as websocat or the Go dialer, working; browsers always send one.

Graceful Shutdown​

Ctrl+C or SIGTERM closes open subscriptions with code 1000 and gives HTTP requests and websocket connections one shared 5-second deadline. A websocket client that has not sent connection_init is closed with code 1002 by the 3-second init timeout, before that deadline.

References​