Back to blog
FILE 0x87·USING APPSYNC AS A PUB/SUB FAN-OUT WITHOUT ANY DATA SOURCE

Using AppSync as a pub/sub fan-out without any data source

May 4, 2026 · aws, appsync, graphql, realtime

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:

I removed:

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.