GraphQL & Server Push
Body-hash GraphQL caching and SSE/WebSocket server push transport.
Body-hash GraphQL caching
GraphQL queries are POST requests with the query and variables in the body. Standard HTTP caching can't handle this because the URL is always the same but the response differs per query.
Swoff's approach:
- JSON-stringify
query+variables - SHA-256 hash via
crypto.subtle.digest() - First 16 hex chars become the cache key →
X-SW-Cache-Key: gql:<hash> - The SW caches under a virtual URL (
/__swc/gql:<hash>) - Different queries to the same endpoint don't collide
Why not a normalized entity cache (like Apollo/Relay)?
- Normalized caches require a schema-aware cache layer that normalizes every response into entities
- This adds significant complexity: schema introspection, entity merging, garbage collection
- Swoff is config-driven and generates auditable code — a normalized cache would need runtime logic that can't be generated statically
- The body-hash approach is simple, deterministic, and produces cache keys that are visible in the SW's Cache Storage
Auto-tags from operation names:
query getTodos { ... }→ tags:["todos"]mutation createTodo(...)→ tags:["todos", "todo"]- Mutation invalidation mirrors the same op-name derivation —
createTodoinvalidatestodos/todo, matchinggetTodos's cache tags. Unlike REST where URL segments carry resource names, GQL op-name tags only cover the top-level operation, not nested entity references. Cross-query entity overlap (e.g.Userin bothgetDashboardandgetTeam) requires explicittags/invalidateoptions.
Tradeoffs vs normalized caches (Apollo, Relay):
| Swoff body-hash | Normalized cache | |
|---|---|---|
| Schema coupling | None — any GQL API works | Requires __typename, id, type policies |
| Cross-query entity updates | Manual via explicit tags | Automatic via __typename + id normalization |
| Runtime cost | 0 kB (generated) | ~32 kB (Apollo) / ~10 kB (Urql exchange) |
| Cache storage | Cache API (disk, persistent) | In-memory |
| Anonymous queries | No op name → no tags | Works via entity extraction |
| Offline mutation replay | ✅ Full queue + auto-invalidation | ❌ Not available |
| Server-driven invalidation | ✅ SSE/WS server push | ❌ Require polling or subscriptions |
The body-hash approach trades automatic entity normalization for simplicity, portability, and SW-level persistence — no schema setup, no type policies, no runtime deps.
Server push transport: SSE vs WebSocket
Both transports are supported via features.serverPush.type.
| Aspect | SSE | WebSocket |
|---|---|---|
| Direction | Server → Client only | Bidirectional |
| Protocol | HTTP (standard) | WS (upgraded) |
| Browser support | All modern browsers | All modern browsers |
| Reconnection | Built-in EventSource auto-reconnects | Manual reconnection logic needed |
| Binary data | Text only | Text + binary |
| Server complexity | Simple — any HTTP server can send SSE | Requires WebSocket handshake + frame handling |
Default: SSE. It's simpler, the browser handles reconnection, and Swoff only needs server-to-client invalidation events — no bidirectional communication is required.
The SW manages the connection directly for reliability across page navigations. The client-side server-push/client.ts is a fallback that starts the connection when the SW is not yet active.
Server event format (SSE):
event: invalidate
data: {"tags": ["todos", "categories"]}Server message format (WebSocket):
{ "event": "invalidate", "tags": ["todos", "categories"] }When the SW receives an invalidate event, it calls invalidateByTags(tags) which removes matching cache entries and queues each invalidated URL through the shared batch refresh queue.
Tag invalidation flow
The tag DB stores both url (cache key) and actualUrl (the real request URL). invalidateByTag deletes cache by url but fetches the live endpoint by actualUrl, ensuring the server is hit correctly.
Tag introspection
Swoff exposes three introspection functions for debugging and dynamic invalidation:
| Client function | SW handler | Description |
|---|---|---|
getUrlsForTag(tag) → { url, actualUrl }[] | GET_URLS_FOR_TAG | Query all URLs cached under a given tag |
getTagsForUrl(url) → string[] | GET_TAGS_FOR_URL | Query all tags associated with a URL |
invalidateMatching(glob) | INVALIDATE_MATCHING | Invalidate all cached entries whose URL matches a glob pattern |
The client functions use MessageChannel to communicate with the SW. invalidateMatching scans all entries in the tag registry, filters by matchGlob(url, globPattern), collects unique tags, and calls invalidateByTag(tag) for each matching tag.