AppSync
After this you can answer: How would you add live seat updates without moving booking correctness into WebSockets?
Picture it
Section titled “Picture it”Where it sits
Section titled “Where it sits”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 designedLegend: 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.
How it works
Section titled “How it works”Follow one proposed update from a committed booking to a second browser that is looking at the same event.
- 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.
- 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.
- The Lambda keeps only two public fields:
eventId(the event that was booked) andmessageId(unique per message). It calls the GraphQL mutationpublishSeatsChanged. 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. - AppSync checks the signature and the role’s
appsync:GraphQLpermission 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. - AppSync compares the result with open subscriptions. A browser that called
onSeatsChanged(eventId: "...")matches when the result’seventIdequals its argument. AppSync pushes the result over that browser’s WebSocket. - 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 PostgresLegend: 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
Under the hood
Section titled “Under the hood”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 source | What the resolver does | Watch out for |
|---|---|---|
| DynamoDB | Builds GetItem, Query, PutItem, UpdateItem and similar requests | Exact table and actions in the data-source role |
| Lambda | Invokes a function with the field’s arguments and context | One call per field per item unless you batch (BatchInvoke) |
| HTTP | Calls an HTTPS endpoint | Endpoint authentication is your job |
| RDS | Runs SQL through the RDS Data API on Aurora | Not a TCP connection to any Postgres; our db.t4g.micro RDS Postgres is not Aurora |
| NONE | Returns the request payload | Useful 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.
| Mode | Who it identifies | Fits |
|---|---|---|
| API key | The app, not a user | Public data; keys expire and are visible in the browser |
| IAM | An AWS identity (role or user), by SigV4 signature | Backends such as our notification Lambda |
| Cognito user pools | A user signed in to a Cognito user pool | Per-user data, groups |
| OIDC | A user from any OpenID Connect provider | An existing identity provider |
| Lambda | Whatever your function decides | Custom 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
Limits and numbers
Section titled “Limits and numbers”At the time of writing; check the AWS docs. Read from the AppSync service quotas page; nothing here was measured in our account.
| Item | Value | What it means for us |
|---|---|---|
| Request execution time | 30 s, not adjustable | Long work belongs in a queue, not a resolver |
| Subscription payload | 240 KB, not adjustable | Send IDs, never a whole seat map |
| Resolver or function code | 32 KB on APPSYNC_JS | Keep logic small; use Lambda for heavy code |
| Functions per pipeline | 10, not adjustable | A long chain is a design smell |
| Subscriptions per WebSocket connection | 200 by default, adjustable | Subscribe only to events on screen |
| Request tokens (us-east-1) | 10,000 per second, adjustable | Tokens depend on processing time and memory, not request count |
| Outbound subscription messages | 1,000,000 per second per API, adjustable; one message = 5 KB | One mutation to 20,000 subscribers is 20,000 messages |
| Inbound subscription messages | 10,000 per second per API, adjustable | Each mutation that triggers a subscription type counts |
| Server-side cache TTL | Up to 3,600 s | Freshness is a product decision |
Numbers are account and Region defaults, not our capacity. MiniTickets has no AppSync load test.
In MiniTickets
Section titled “In MiniTickets”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 page reads seats through
apps/web/src/features/events/hooks/use-event.ts→useEvent, a tRPC query with astaleTimeof 10 seconds. It refetches when the tab regains focus. apps/web/src/features/bookings/hooks/use-book-ticket.ts→useBookTicketinvalidates the event query in the browser that booked. Its own comment says it plainly: other people’s browsers keep their copy until theirstaleTimeruns out. The row lock in the API keeps that safe; the number on screen can just be old.- The event read,
events.byId, is apublicProcedure. Seat counts are public, so a push that says “event X changed” leaks nothing.
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.
Connections
Section titled “Connections”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.
| Connection | What allows it | Boundary to keep |
|---|---|---|
| API task → booking-events topic (exists) | Task role sns:Publish on that one topic ARN | Publishing 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 topic | Its own copy; email and tickets unaffected |
| Notification queue → Lambda (proposed) | Event source mapping; relay role sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes on that queue | Own visibility timeout, retries and DLQ |
| Lambda → AppSync mutation (proposed) | IAM auth mode; relay role appsync:GraphQL on .../types/Mutation/fields/publishSeatsChanged | No 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 |
When it fails
Section titled “When it fails”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 awsLegend: red = lost notifications; recovery is always a fresh query. AppSync parts are designed, not built.
| What you see | Why | Fix |
|---|---|---|
Not Authorized to access publishSeatsChanged on type Mutation | Relay role lacks appsync:GraphQL on that field, or the field has no @aws_iam | Check 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 for | Have the publisher select every field subscribers use |
| Seat counts freeze after a network blip | The client assumed subscriptions replay missed messages | Always refetch after reconnect; treat pushes as hints |
| HTTP 200 but the UI shows nothing | GraphQL put the error in the errors array, not the status code | Check errors on every response; log the field path |
Data-source AccessDenied in resolver logs | The data-source role lacks the backend action | Fix 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 / 100x
Section titled “At 10x / 100x”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.
Common interview traps
Section titled “Common interview traps”- “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.
Design question
Section titled “Design question”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 appLegend: 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.
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Can a JavaScript resolver call an npm package or fetch a URL?
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?
How do you keep cached private data private?
What happens to a booking push while a user is offline?
AppSync, API Gateway or our tRPC?
Remember it
Section titled “Remember it”- A resolver connects one GraphQL field to one data source.
APPSYNC_JSis 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
Section titled “Advanced”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.
aws appsync list-graphql-apis --region us-east-1aws appsync list-data-sources --api-id "$API_ID" --region us-east-1aws appsync list-resolvers --api-id "$API_ID" --type-name Mutation --region us-east-1These 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.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What exactly causes a subscription push in AppSync?
- Why can an
APPSYNC_JSresolver not callfetch? - Which two separate permissions sit between a browser and a DynamoDB table behind AppSync?
- How does a browser recover pushes it missed while offline?
- Why must the notification Lambda drop
userEmailfrom 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/