You know that moment when “it should be simple” puts on a clown wig and honks a tiny horn? That was me, trying to wire a React client to AWS AppSync for real-time GraphQL updates. Four hours, three coffees, and one thousand console.logs later… it works. Here’s the story, the fixes, and enough code breadcrumbs to save Future You from my timeline.
Spoiler for the curious: I tried Cursor and a couple other AI copilots mid-chaos—useful for rubber-ducking, but they didn’t splice the wires for me. I also started with subscription-transport-ws (RIP, old friend), then eventually ditched libs and went full native WebSocket. That’s when the fog lifted.
The setup
Goal: React client subscribes to onPublish and renders messages in real time.
Reality: Handshake rituals, headers, base64, keep-alives, reconnects, and a protocol that likes its messages just so.
The working component is intentionally verbose with logs, state, and safety rails, because “silent failure” is so last season.
What finally clicked
1) Build the right AppSync Real-Time URL
- Start with your HTTP endpoint (env:
VITE_APPSYNC_HTTP_ENDPOINTorVITE_APPSYNC_API_URL). - Swap
appsync-api→appsync-realtime-api. -
Append
?header=<base64(authHeader)>&payload=<base64({})>where:-
authHeader = { host, "x-api-key": <your-api-key> }.
-
Yes, standard base64—not base64url. That one detail ate 20 minutes of my life.
2) Speak the protocol’s love language
Use the graphql-ws subprotocol. The dance goes like this:
connection_init- Wait for
connection_ack -
startwith:
-
id(unique per subscription), -
payload.dataas a stringified JSON of your GraphQL query, - Optional
extensions.authorizationwith{ host, "x-api-key" }(handy with API key auth).
Miss any of those and AppSync just stares into the middle distance.
3) Parse data like it’s nested Russian dolls
AppSync will send type: "data" and the payload’s data might itself be a JSON string. Parse it, then read onPublish. Defensive parsing here means fewer 3AM mysteries.
4) Respect the keep-alives
You’ll get ka / connection_keep_alive. Flip a heartbeat flag and update your status UI so you know the line is alive. Future-you will send you a thank-you pizza.
5) Be nice to your users (and yourself) with reconnection
Exponential backoff up to a limit. Clear timeouts on unmount. Send a stop when you’re done. Leave the room tidier than you found it.
The subscription query (tiny but mighty)
subscription OnPublish {
onPublish {
name
body
}
}
Wired straight into the start message as stringified JSON under payload.data.
Code crumbs you’ll likely reuse
Build the Real-Time URL (HTTP → WSS with auth headers)
const buildWebSocketUrl = () => {
const httpEndpoint = import.meta.env.VITE_APPSYNC_HTTP_ENDPOINT || import.meta.env.VITE_APPSYNC_API_URL;
const apiKey = import.meta.env.VITE_APPSYNC_API_KEY;
const host = new URL(httpEndpoint).host;
const realtimeHost = host.replace("appsync-api", "appsync-realtime-api");
const header = btoa(JSON.stringify({ host, "x-api-key": apiKey }));
const payload = btoa(JSON.stringify({}));
return `wss://${realtimeHost}/graphql?header=${header}&payload=${payload}`;
};
That btoa(JSON.stringify({})) producing e30= is correct. Trust the emptiness.
Opening handshake + starting the subscription
const ws = new WebSocket(buildWebSocketUrl(), "graphql-ws");
ws.onopen = () => {
ws.send(JSON.stringify({ type: "connection_init", payload: {} }));
};
const startMessage = {
id: `sub_${Date.now()}`,
type: "start",
payload: {
data: JSON.stringify({ query: ON_PUBLISH_SUBSCRIPTION_QUERY.trim() }),
extensions: {
authorization: { host, "x-api-key": apiKey }
}
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "connection_ack") {
ws.send(JSON.stringify(startMessage));
}
};
The extensions.authorization block was the missing puzzle piece for me with API key auth.
Receiving data (defensive parsing)
if (msg.type === "data" && msg.payload?.data) {
const parsed = typeof msg.payload.data === "string"
? JSON.parse(msg.payload.data)
: msg.payload.data;
const onPublish = parsed?.onPublish ?? parsed?.data?.onPublish;
if (onPublish) setMessages(prev => [onPublish, ...prev]);
}
Handles both the “stringified JSON” and “already-an-object” variants you might see.
What I tried before it worked (a small gallery of almosts)
-
Libraries first: Reached for
subscription-transport-wsout of habit. It’s deprecated and doesn’t map cleanly to AppSync’s expectations. After a few protocol mismatches, I bailed and went nativeWebSocket. Painful? A bit. Clarifying? Absolutely. -
AI copilots (Cursor + others): Great at reshaping code and suggesting patterns, but the devil here lives in undocumented quirks: vanilla base64, exact
payload.datashape, and when to sendstart. The final mile needed eyeballs on raw frames. - Base64url headers: AppSync wants plain base64. Using base64url gave me a very polite nothing.
-
Starting before
connection_ack: It’s tempting. Resist. The order matters. - Skipping UI telemetry: A tiny panel for connection state, heartbeat, subscription ID, and last error pays for itself immediately.
The full working component
I left the logs noisy and the UI honest: connection state, subscription status, heartbeat, reconnection attempts, and a scrollable message list. Copy it, prune it, or keep it loud until prod—your call.
Quick checklist (pin this)
wss://...appsync-realtime-api.../graphql?header=<b64>&payload=<b64>- Subprotocol:
"graphql-ws" -
connection_init→ wait forconnection_ack -
startwithpayload.data = JSON.stringify({ query }) - Optional
extensions.authorization(host,x-api-key) for API key flows - Parse
payload.datadefensively - Handle
ka/keep-alive - Exponential reconnect with limits
- Clean up:
stop+closeon unmount
Closing thoughts
Real-time isn’t hard; it’s picky. Once you respect the handshake and payload shapes, AppSync hums along nicely. The “native WS first principles” pass taught me more than a stack of blog posts, and the extra status UI + chatty logs turned a cryptic failure into a solvable puzzle.
If you’re wiring this up with your own mutations and resolvers, drop a note about your stack and any quirks you hit. There’s always one more header, one more “tiny” detail, and one more developer who could use breadcrumbs.
Yeah Yeah!!, AI helped me rewrite this experience. but, the app, the debugging, and the code are very real.
Top comments (0)