Using AppSync as a pub/sub fan-out without any data source
I had two clients — a web app and a native iOS app — both polling my backend every few seconds to learn that an assistant reply had finished. Polling worked, but each tab and each backgrounded phone was an open DynamoDB read budget and a continuous load on the API process. I wanted real-time updates without giving up the write-to-DynamoDB-directly pattern the backend already used.
What was happening
The backend wrote messages to DynamoDB on its own schedule. Clients
called /conversations/{id}/poll every few seconds and asked "has
anything changed since timestamp X?" When the answer was yes, they
re-fetched /messages. It was correct but wasteful, and on slow
homelab restarts the polling herd would pile up against an empty
upstream.
What I found
AppSync subscriptions are usually pitched as the live half of a GraphQL CRUD app — you point AppSync at DynamoDB or Lambda as a data source, clients subscribe, and any mutation through the GraphQL layer fans out to subscribers automatically.
I didn't want that. The backend was the source of truth and it already wrote to DynamoDB directly. I just wanted a pub/sub bus that the backend could fire into after a successful write, and that clients could subscribe to.
AppSync supports a NONE data source: the resolver does nothing but echo its arguments back. That's enough for fan-out. The schema is trivial:
type Mutation {
publishMessageAdded(convId: ID!, msg: AWSJSON!): MessageEvent
@aws_api_key
}
type Subscription {
onMessageAdded: MessageEvent
@aws_subscribe(mutations: ["publishMessageAdded"])
@aws_api_key
}
The JS resolver for publishMessageAdded is literally:
export function request(ctx) { return { payload: ctx.args }; }
export function response(ctx) { return ctx.result; }
The backend writes to DynamoDB, then fires the mutation fire-and-forget. Every connected client gets the event over WebSocket within a few hundred milliseconds.
The fix
After the cutover:
- Backend: write to DDB, then call
appsync.publish_message_added(...). - Web: small WebSocket client (no SDK) that follows the AppSync Realtime protocol.
- iOS:
URLSessionWebSocketTaskdoing the same protocol natively.
I removed:
- The old
pollTickJavaScript and its iOS twin. - A throttle in
append_to_messagethat batched writes to avoid polling overload. - The defensive end-of-job touch (kept it actually, since it still drives sidebar sort).
One gotcha I'd flag: subscription arguments with filters had a delivery quirk where some payload fields came through null. I sidestepped it by leaving the subscription unfiltered and routing events to per-conversation listeners on the client. Cheap, and the client already knows which conversation it's looking at.
What I'd do differently
If I were starting from scratch I'd skip polling entirely and use AppSync from day one. The polling pattern made the early prototype easier to reason about but left a "remove polling" item on the backlog for months. The cleanup ended up being a one-evening job across both clients because the publish surface was small. Worth doing earlier than I did.