Skip to content

AppSync

13 min read · main part only; folded sections are optional

After this you can answer: How would you add live seat updates without moving booking correctness into WebSockets?

Today a browser learns about other people’s bookings only when it asks again. The proposed path reuses the booking event we already publish and turns it into a small “refresh this event” push.

flowchart TB
browser["Browser"] -->|"books - sid cookie, booking procedure"| api["API service"]
api -->|"commits booking and outbox row - TCP 5432 from API SG"| db["Postgres"]
api -->|"relays outbox event - task role sns:Publish on one topic"| topic["minitickets-dev-booking-events SNS"]
topic -->|"copies event - proposed queue policy, sqs:SendMessage"| queue["Notification SQS (designed, not built)"]
queue -->|"polled - proposed mapping, relay role sqs:ReceiveMessage"| relay["Notification Lambda (designed, not built)"]
relay -->|"runs mutation - proposed role appsync:GraphQL on one field"| sync["AppSync GraphQL API (designed, not built)"]
sync -->|"pushes eventId - authorized WebSocket subscription"| browser
class browser,api app
class db,topic aws
class queue aws
class queue designed
class relay app
class relay designed
class sync service
class sync designed

Legend: highlighted = AppSync; app = our code; AWS = managed service; dashed = designed, not built. The browser, API, Postgres and topic exist in code.

Open the booking-events topic in the map →. It shows the two consumers that exist today (email and tickets). AppSync has no map piece because nothing is deployed or defined.

Follow one proposed update from a committed booking to a second browser that is looking at the same event.

  1. A customer books through the existing API. One Postgres transaction writes the booking and an outbox row. The outbox is a table of events waiting to be published, so the event cannot exist without the booking.
  2. The existing outbox relay publishes that row to the SNS topic. A new SQS queue, subscribed to the topic, receives its own copy. An event source mapping polls the queue and calls a new notification Lambda with a batch.
  3. The Lambda keeps only two public fields: eventId (the event that was booked) and messageId (unique per message). It calls the GraphQL mutation publishSeatsChanged. A mutation is a GraphQL operation that changes data or, here, only announces a change. The request is signed with the Lambda’s IAM credentials.
  4. AppSync checks the signature and the role’s appsync:GraphQL permission on that one field. Then it runs the field’s resolver. The resolver uses a NONE data source: it touches no database and just returns its input as the mutation result.
  5. AppSync compares the result with open subscriptions. A browser that called onSeatsChanged(eventId: "...") matches when the result’s eventId equals its argument. AppSync pushes the result over that browser’s WebSocket.
  6. The browser marks its event query stale and refetches it through the existing API. The fresh answer comes from Postgres. The push says “something changed”; the query says what is true now.
sequenceDiagram
participant B as Second browser
box rgb(237,233,254) AppSync
participant A as AppSync (designed, not built)
end
participant Q as Notification SQS (designed, not built)
participant L as Notification Lambda (designed, not built)
participant P as Existing API
B->>A: Subscribe onSeatsChanged(eventId) over WebSocket
Q->>L: Batch via event source mapping
L->>A: Mutation publishSeatsChanged, IAM-signed
A->>A: Authorize field, run NONE resolver
A-->>L: Mutation result
A-->>B: Push eventId and messageId
L-->>Q: Failed message IDs only
B->>P: Refetch event (existing tRPC query)
P-->>B: Current seats from Postgres

Legend: arrows show order; everything except the browser and the existing API is designed, not built.

GraphQL is an API query language with a typed schema. The schema lists every type and field and says which ones are queries (reads), mutations (writes) and subscriptions (pushes). The schema only describes the contract. Resolvers do the work, and authorization decides who may call each field. AWS: real-time data

A sketch of the schema for this design (not deployed anywhere):

# Default auth mode: API key. Extra auth mode: IAM (for the publisher).
type SeatsChanged @aws_api_key @aws_iam {
eventId: ID!
messageId: ID!
}
type Query { health: String }
type Mutation {
publishSeatsChanged(eventId: ID!, messageId: ID!): SeatsChanged @aws_iam
}
type Subscription {
onSeatsChanged(eventId: ID!): SeatsChanged
@aws_subscribe(mutations: ["publishSeatsChanged"])
}

@aws_subscribe is the link: every successful publishSeatsChanged result goes to matching onSeatsChanged subscribers. The subscription argument eventId filters by the result field of the same name. Subscribers receive only the fields the mutation returned. If the publisher’s selection set leaves out a field, subscribers see null for it, or an error when the field is required.

Resolvers. A unit resolver has a request handler and a response handler. The request handler builds a data-source request; AppSync runs it; the response handler shapes the result. Our mutation’s resolver is two tiny functions:

// Mutation.publishSeatsChanged, NONE data source: no backend call, just echo the payload.
export function request(ctx) {
return { payload: { eventId: ctx.args.eventId, messageId: ctx.args.messageId } };
}
export function response(ctx) {
return ctx.result;
}

JavaScript resolvers run on the APPSYNC_JS runtime. It is a restricted JavaScript subset, not Node.js. There is no async/await, no promises, no network calls and no npm packages at run time. The resolver describes a request; AppSync performs it. When you need a full runtime, use a Lambda data source. AWS: APPSYNC_JS runtime features

Pipeline resolvers. A pipeline resolver runs up to 10 AppSync functions in order, sharing a ctx.stash object between them. For example: function 1 checks access, function 2 reads an item, function 3 writes an audit record. These are AppSync functions, not Lambda functions. A pipeline gives order, not a transaction: if step 3 fails, step 2’s write is not rolled back. AWS: pipeline resolvers

Data sources. Each data source is a configured backend, usually with an IAM role that AppSync assumes:

Data sourceWhat the resolver doesWatch out for
DynamoDBBuilds GetItem, Query, PutItem, UpdateItem and similar requestsExact table and actions in the data-source role
LambdaInvokes a function with the field’s arguments and contextOne call per field per item unless you batch (BatchInvoke)
HTTPCalls an HTTPS endpointEndpoint authentication is your job
RDSRuns SQL through the RDS Data API on AuroraNot a TCP connection to any Postgres; our db.t4g.micro RDS Postgres is not Aurora
NONEReturns the request payloadUseful for publish-only mutations like ours

Subscriptions. The browser opens a WebSocket to the real-time endpoint, sends its auth details, then registers subscriptions on that connection. AppSync keeps the connection and subscription state. It does not watch your database: a change made outside an AppSync mutation sends nothing. It also does not store missed messages. A browser that was offline for one minute gets nothing for that minute. AWS: real-time WebSocket client

Auth modes. One API has a default mode and can add more. Each field or type says which modes may call it.

ModeWho it identifiesFits
API keyThe app, not a userPublic data; keys expire and are visible in the browser
IAMAn AWS identity (role or user), by SigV4 signatureBackends such as our notification Lambda
Cognito user poolsA user signed in to a Cognito user poolPer-user data, groups
OIDCA user from any OpenID Connect providerAn existing identity provider
LambdaWhatever your function decidesCustom tokens, such as our opaque sid cookie

AWS: authorization and authentication

Caching. AppSync can put a managed cache in front of resolvers. Full request caching caches every resolver’s result, keyed by its arguments and the caller’s identity. Per-resolver caching lets you choose which resolvers cache and on which keys. Operation-level caching stores the whole query response. TTLs go up to 3,600 seconds. A wrong key can serve one user’s data to another, so private fields must include identity in the key. AWS: caching

At the time of writing; check the AWS docs. Read from the AppSync service quotas page; nothing here was measured in our account.

ItemValueWhat it means for us
Request execution time30 s, not adjustableLong work belongs in a queue, not a resolver
Subscription payload240 KB, not adjustableSend IDs, never a whole seat map
Resolver or function code32 KB on APPSYNC_JSKeep logic small; use Lambda for heavy code
Functions per pipeline10, not adjustableA long chain is a design smell
Subscriptions per WebSocket connection200 by default, adjustableSubscribe only to events on screen
Request tokens (us-east-1)10,000 per second, adjustableTokens depend on processing time and memory, not request count
Outbound subscription messages1,000,000 per second per API, adjustable; one message = 5 KBOne mutation to 20,000 subscribers is 20,000 messages
Inbound subscription messages10,000 per second per API, adjustableEach mutation that triggers a subscription type counts
Server-side cache TTLUp to 3,600 sFreshness is a product decision

Numbers are account and Region defaults, not our capacity. MiniTickets has no AppSync load test.

Explain-only: designed, not built. There is no GraphQL schema, AppSync API, resolver, notification queue or notification Lambda in our code or infrastructure.

What exists today, and why live updates would help:

The event source already exists. apps/api/src/services/outbox.ts → startOutboxRelay publishes each committed booking to minitickets-dev-booking-events, retrying from 1 to 60 seconds. infra/src/jobs/booking-events.ts → createBookingEvents subscribes the email and tickets queues with raw delivery. A third subscription would give the notification path its own copy, so a broken AppSync publisher can never delay a confirmation email or a ticket.

The message is BookingConfirmedMessage in packages/shared. It carries messageId, bookingId, eventId (optional for old messages), userEmail and more. The notification Lambda must forward only eventId and messageId. The customer’s email address must never reach a public subscription. A message without eventId is skipped: it is a hint, so dropping one costs at most one refresh.

Auth for browsers: the payload is public, so the subscription could use an API key. A key identifies our web app, not a person, so it does not stop someone else from opening many connections. If the field ever carries per-user data, use a Lambda authorizer. Our sid cookie is an opaque session ID stored in Redis, so that Lambda would need network access to Valkey. Cognito or OIDC would mean a new identity system next to our sessions.

AppSync has two separate checks: who may call a field, and what AppSync may do to a data source. Passing the first never grants the second.

ConnectionWhat allows itBoundary to keep
API task → booking-events topic (exists)Task role sns:Publish on that one topic ARNPublishing does not depend on any consumer being up
Topic → notification queue (proposed)New SNS subscription; queue policy allows sns.amazonaws.com sqs:SendMessage with aws:SourceArn = our topicIts own copy; email and tickets unaffected
Notification queue → Lambda (proposed)Event source mapping; relay role sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes on that queueOwn visibility timeout, retries and DLQ
Lambda → AppSync mutation (proposed)IAM auth mode; relay role appsync:GraphQL on .../types/Mutation/fields/publishSeatsChangedNo permission to query customer data
Browser → subscription (proposed)API key (public hints) or a Lambda authorizer (per user)A key is not a user identity
AppSync → DynamoDB or Lambda data source (general)Data-source role trusted by appsync.amazonaws.com, with exact actions (dynamodb:GetItem, lambda:InvokeFunction)The caller’s auth does not extend to the backend
flowchart TB
push["Browser connected and subscribed"] --> drop{"WebSocket drops?"}
drop -->|"yes"| missed["Pushes during the gap are lost"]
missed --> reconnect["Reconnect and subscribe again"]
reconnect --> refetch["Refetch event from existing API"]
drop -->|"no"| hint["Push arrives, refetch event"]
refetch --> truth["Seats from Postgres"]
hint --> truth
class push,reconnect,refetch,hint app
class drop service
class drop designed
class missed failure
class truth aws

Legend: red = lost notifications; recovery is always a fresh query. AppSync parts are designed, not built.

What you seeWhyFix
Not Authorized to access publishSeatsChanged on type MutationRelay role lacks appsync:GraphQL on that field, or the field has no @aws_iamCheck the role policy ARN and the schema directive together
Subscribers get null or “Cannot return null for non-nullable type”The mutation’s selection set omitted a field the subscription asks forHave the publisher select every field subscribers use
Seat counts freeze after a network blipThe client assumed subscriptions replay missed messagesAlways refetch after reconnect; treat pushes as hints
HTTP 200 but the UI shows nothingGraphQL put the error in the errors array, not the status codeCheck errors on every response; log the field path
Data-source AccessDenied in resolver logsThe data-source role lacks the backend actionFix that role; the caller’s token is not the problem

Turn on AppSync logging at error level first, and never log full request bodies with tokens. Watch 5XXError, Latency, and the real-time metrics for connection and publish errors. AWS: monitoring

At 10x, a busy sale means many bookings per second for one event. Every booking makes one mutation and one push per viewer. Each push makes every viewer refetch. The API, not AppSync, takes the load: 2,000 viewers times 10 bookings per second is 20,000 refetches per second. Coalesce instead. Give the event source mapping a batching window of about one second. The Lambda then sends one publishSeatsChanged per distinct eventId in the batch, not one per booking. Clients add jitter, a small random delay, before they refetch.

At 100x, put the new number in the push instead of a hint. The publisher reads seatsLeft once and sends it in the payload. 20,000 viewers then cost 20,000 outbound messages and zero refetches. That is a deliberate change: the number can be a moment old, so the booking API must still be the only judge.

Reconnect storms matter too. After an outage every client reconnects and refetches at once. Add jittered backoff to reconnects. Measure connection rate against the 2,000 connect requests per second default. For huge broadcast audiences, also compare AppSync Events, a separate channel-based pub/sub API type.

  • “One GraphQL request is one database call.” Every field can have a resolver. A list of 50 items with a nested field can mean 51 backend calls.
  • “A pipeline resolver is a transaction.” It runs steps in order. A failed step does not undo earlier writes.
  • “Writing to DynamoDB triggers the subscription.” Only an AppSync mutation triggers it. A change made elsewhere needs a publisher.
  • “Subscriptions are reliable delivery.” They are best-effort pushes with no replay. Keep a queryable source of truth.
  • “An API key authenticates users.” It identifies the client app. Use Cognito, OIDC, IAM or a Lambda authorizer for people.

Show live availability to 20,000 people watching one sale, without risking oversell.

Keep booking where it is: one Postgres transaction with a row lock. After commit, the outbox publishes the event. A notification consumer coalesces events per event ID and publishes at most one AppSync mutation per second. Each viewer subscribes only to the event on screen. On a push, or on reconnect, the client refetches with jitter. The “Book” button still calls the booking API, which can still answer “sold out”.

flowchart TB
book["Booking transaction (exists)"] --> outbox["Outbox to SNS (exists)"]
outbox --> coalesce["Coalesce per event, 1 per second (designed, not built)"]
coalesce --> sync["AppSync mutation (designed, not built)"]
sync --> viewers["Subscribed viewers"]
viewers --> refetch["Refetch with jitter"]
refetch --> book
class book,refetch app
class outbox aws
class coalesce app
class coalesce designed
class sync service
class sync designed
class viewers app

Legend: dashed = designed, not built; the loop back shows that every decision still goes through the booking transaction.

Why AppSync and not API Gateway WebSockets? With API Gateway you store connection IDs yourself and send to each one. AppSync keeps connections and subscription filtering for you. Why not GraphQL for everything? Our web app already has typed tRPC hooks. Adding a schema just for pushes is a cost; it pays off only when live updates are a product need.

Can a JavaScript resolver call an npm package or fetch a URL?
No. APPSYNC_JS is a restricted runtime with no network access or async code. Use an HTTP data source, or a Lambda data source when you need a full runtime.
How do you stop the N+1 problem?
Batch nested reads: a Lambda data source with BatchInvoke receives up to 2,000 items in one call, or design the parent resolver to fetch what children need.
How do you keep cached private data private?
Include the caller’s identity in the cache key, or do not cache that resolver. Test with two different users.
What happens to a booking push while a user is offline?
It is lost. Subscriptions are not a queue. The client refetches after reconnect; if the product needs history, store it and query it.
AppSync, API Gateway or our tRPC?
tRPC fits our TypeScript web app on ALB. API Gateway fits HTTP routes or raw WebSocket messages. AppSync fits when a GraphQL schema, managed resolvers and filtered subscriptions are the contract.
  • A resolver connects one GraphQL field to one data source.
  • APPSYNC_JS is restricted JavaScript, not Node.js: no async, no network calls.
  • Subscriptions push mutation results; they never watch the database and never replay.
  • Caller auth and data-source permissions are two separate checks.
  • In MiniTickets, live seats would be a refresh hint; Postgres still decides.
Advanced: Console, read-only CLI, filtering and invalidation

Console: AppSync → APIs → a GraphQL API → Schema, Data sources, Functions, Settings (auth modes), Caching, Monitoring. This project has no API to open yet.

Terminal window
aws appsync list-graphql-apis --region us-east-1
aws appsync list-data-sources --api-id "$API_ID" --region us-east-1
aws appsync list-resolvers --api-id "$API_ID" --type-name Mutation --region us-east-1

These only read. This repository supplies no API ID.

Enhanced subscription filtering. Besides argument matching, a subscription’s response handler can set filters with extensions.setSubscriptionFilter, for example “only events at this venue”. The filter runs inside AppSync, so unmatched messages never reach the client. Subscription invalidation (extensions.invalidateSubscriptions) can close subscriptions from a mutation, for example when a user loses access. The account default is 100 invalidation requests per second. AWS: enhanced filtering

AppSync Events is a second API type: channels and namespaces, publish over HTTP or WebSocket, no GraphQL schema. For “push a small message to everyone on this channel” it can be simpler than a mutation plus a subscription. This page uses the GraphQL API because the brief asks for resolvers and data sources.

Merged APIs combine several source GraphQL APIs into one endpoint, so teams can own separate schemas. Private APIs are reachable only from a VPC through an interface endpoint.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What exactly causes a subscription push in AppSync?
  2. Why can an APPSYNC_JS resolver not call fetch?
  3. Which two separate permissions sit between a browser and a DynamoDB table behind AppSync?
  4. How does a browser recover pushes it missed while offline?
  5. Why must the notification Lambda drop userEmail from the booking message?

Redraw it from memory: the booking transaction, outbox, SNS, notification queue and Lambda, AppSync and the refetching browser

Try this: open use-book-ticket.ts and find the comment about other people's browsers; say which line of the design closes that gap

Read this book online: https://book.theversehub.in/