Skip to content
Developers · 8 min read

Web push in Go with webpush-go

Go is a natural fit for push senders: lots of small HTTPS requests, easy concurrency. The common library is SherClockHolmes/webpush-go. This guide covers keys, a subscribe handler, sending, cleanup and a bounded worker pool.

Updated By the Pusharoo team
In short
  • go get github.com/SherClockHolmes/webpush-go.
  • webpush.GenerateVAPIDKeys() returns a private and public key; store them once.
  • webpush.SendNotification(payload, sub, &Options{…}) returns an *http.Response — always close the body.
  • Delete subscriptions on 404/410; use a bounded pool of goroutines for large lists.

1. Keys

package main import ( "fmt" webpush "github.com/SherClockHolmes/webpush-go" ) func main() { priv, pub, err := webpush.GenerateVAPIDKeys() if err != nil { panic(err) } fmt.Println("public:", pub) // give to the browser as applicationServerKey fmt.Println("private:", priv) // keep secret (env var / secret manager) }

2. Save subscriptions

func subscribe(w http.ResponseWriter, r *http.Request) { var s webpush.Subscription if err := json.NewDecoder(r.Body).Decode(&s); err != nil || s.Endpoint == "" { http.Error(w, "bad subscription", http.StatusBadRequest); return } store.Upsert(s) // key on s.Endpoint; keep s.Keys.P256dh and s.Keys.Auth w.WriteHeader(http.StatusCreated) }

The browser side is the same for every backend:

// browser: ask on a click, subscribe, send the subscription to your server const VAPID_PUBLIC = 'YOUR_PUBLIC_KEY_BASE64URL'; document.querySelector('#notify').addEventListener('click', async () => { const reg = await navigator.serviceWorker.register('/sw.js'); if (await Notification.requestPermission() !== 'granted') return; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC }); await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) }); });

3. Send one notification

func send(s *webpush.Subscription, msg []byte) error { resp, err := webpush.SendNotification(msg, s, &webpush.Options{ Subscriber: "ops@example.com", // contact for push services VAPIDPublicKey: os.Getenv("VAPID_PUBLIC"), VAPIDPrivateKey: os.Getenv("VAPID_PRIVATE"), TTL: 86400, Urgency: webpush.UrgencyNormal, Topic: "release", // replaces an undelivered message with the same topic }) if err != nil { return err } defer resp.Body.Close() switch resp.StatusCode { case 404, 410: return store.Delete(s.Endpoint) case 429: return fmt.Errorf("rate limited: retry after %s", resp.Header.Get("Retry-After")) } if resp.StatusCode >= 400 { return fmt.Errorf("push failed: %s", resp.Status) } return nil }

Push services answer 201 when they accept a message. 404 or 410 means the subscription is gone — delete it. 413 means the payload is too large (keep it under about 4 KB), 429 means slow down. Details in the Web Push protocol.

4. A bounded worker pool

func broadcast(subs []webpush.Subscription, msg []byte) { jobs := make(chan *webpush.Subscription) var wg sync.WaitGroup for i := 0; i < 32; i++ { // tune to your bandwidth and the push services’ limits wg.Add(1) go func() { defer wg.Done(); for s := range jobs { if err := send(s, msg); err != nil { log.Println(err) } } }() } for i := range subs { jobs <- &subs[i] } close(jobs); wg.Wait() }

Share one http.Client with keep-alive (webpush-go accepts a custom client via Options.HTTPClient) so connections to each push service are reused.

Payload tips

  • Send compact JSON: title, body, url, optional image. The service worker builds the notification.
  • Stay under about 4 KB after encryption.
  • Use Urgency: high only for genuinely urgent alerts; it can wake devices.
  • Version your payload format so old workers ignore fields they don’t know.

Storing subscriptions in Postgres

CREATE TABLE push_subscriptions ( endpoint text PRIMARY KEY, p256dh text NOT NULL, auth text NOT NULL, user_id bigint NULL, created_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now() ); -- upsert on every visit INSERT INTO push_subscriptions (endpoint, p256dh, auth) VALUES ($1, $2, $3) ON CONFLICT (endpoint) DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth, last_seen_at = now();

Stream rows with a cursor instead of loading the whole table into memory when broadcasting.

Timeouts, contexts and shutdown

Give the HTTP client a timeout (10–30 seconds is typical) so one slow push service can’t stall a worker. If you send from a long-running service, stop accepting new jobs on SIGTERM, let in-flight sends finish, and persist the position in the list so a restart resumes rather than starting over — sending duplicates is worse than sending late. Record per-host error counts to spot a single push service having problems.

Testing locally

  1. Run your server on localhost (service workers allow it without HTTPS).
  2. Subscribe in Chrome and log the subscription JSON.
  3. Call your send function and confirm a 201.
  4. Use Chrome DevTools → Application → Service workers → Push for handler-only tests without a server.

When not to build it

Everything above is the do-it-yourself route: you own the keys, storage, sending queue and cleanup. If what you need is marketing-style notifications to anonymous visitors in your Go app — a branded prompt, segments, schedules, A/B tests, automations, iPhone Home Screen handling — Pusharoo does that for free: add the snippet to your base template and upload sw.js. You can still call Pusharoo’s REST API from Go for transactional sends (POST /api/v1/notify) and receive signed webhooks when people subscribe, so the two approaches mix well.

Questions

Which Go library should I use for web push?

SherClockHolmes/webpush-go is the most widely used. Check its repository for the latest version and Go module path.

Is the Subscriber option an email?

It’s the VAPID contact (sub claim): an email address or https URL where push services can reach you.

How many concurrent sends are safe?

Start with a few dozen workers, watch for 429 responses and back off using Retry-After.

Can I send web push from a Go serverless function?

Yes for small sends: one request per subscription fits a short function. For large lists, enqueue work and process it with a long-running worker so timeouts don’t cut a broadcast in half.

Does webpush-go support aes128gcm encryption?

Yes. It implements the RFC 8291 aes128gcm content encoding that all current browsers expect; you don’t choose the encryption yourself.

More in Developers: The Web Push API in JavaScript: a working example · Service worker push events, done right · Web push notifications in PHP · Laravel browser push notifications (no Firebase) · Web push notifications in React and Next.js · Declarative Web Push, explained · Firebase web push vs. standard web push · Django push notifications to the browser · Web push notifications in Ruby on Rails · Angular push notifications with SwPush · Push notifications in Vue 3 and Nuxt · Push notifications in SvelteKit · All guides

Free for life. No limits.

Unlimited subscribers, campaigns and automations. No badges. About five minutes to install.