AWS AppSync GraphQL: Real-Time APIs with DynamoDB
AWS AppSync is a fully managed GraphQL service that removes the operational burden of running a GraphQL server. It handles connection management, caching, real-time subscriptions over WebSockets, offline data synchronization, and fine-grained authorization — all without you provisioning a single server. Connect AppSync to DynamoDB, Lambda, OpenSearch, HTTP endpoints, or relational databases through RDS Data API, and you have a unified data layer for web and mobile applications that can handle millions of concurrent connections.
This guide covers everything you need to build production-ready AppSync APIs: schema design with SDL, VTL and JavaScript resolvers, DynamoDB mapping templates, real-time subscriptions, the five supported authentication modes, and offline conflict resolution. It also compares AppSync against the classic API Gateway + Lambda approach so you can choose the right tool for each use case.
Table of Contents
- AppSync Overview and Architecture
- Schema Definition Language (SDL)
- Resolvers: VTL vs JavaScript
- DynamoDB Resolvers with Mapping Templates
- Real-Time Subscriptions
- Authentication Modes
- Offline Sync and Conflict Resolution
- AppSync vs API Gateway + Lambda
- Creating an API with AWS CLI and boto3
- Frequently Asked Questions
AppSync Overview and Architecture
GraphQL was designed by Facebook to solve the under-fetching and over-fetching problems inherent in REST. Clients specify exactly what fields they need, and a single request can traverse relationships across multiple data sources. AppSync takes this a step further by providing a managed runtime that handles the boilerplate: parsing the GraphQL document, routing each field to the correct data source resolver, batching DynamoDB reads with DataLoader semantics, and pushing mutations to subscribed clients over a persistent WebSocket connection.
The AppSync execution model has three layers:
- Schema — The SDL contract that defines every type, query, mutation, and subscription the API exposes.
- Resolvers — Per-field or pipeline functions that translate a GraphQL operation into a data source request and map the response back to the expected type. Resolvers can be written in Apache Velocity Template Language (VTL) or JavaScript (APPSYNC_JS runtime).
- Data Sources — Connections to DynamoDB tables, Lambda functions, HTTP endpoints, OpenSearch domains, or RDS clusters. Each resolver is attached to exactly one data source.
Schema Definition Language (SDL)
Every AppSync API starts with a GraphQL schema written in SDL. The schema is the single source of truth for your API contract. AppSync extends the standard SDL with directives like @aws_auth, @aws_subscribe, and @aws_cognito_user_pools to attach authorization rules directly in the schema.
Here is a complete schema for a real-time chat application backed by DynamoDB:
# schema.graphql — AppSync chat API
type Message {
id: ID!
roomId: ID!
content: String!
authorId: String!
authorName: String!
createdAt: AWSDateTime!
updatedAt: AWSDateTime
}
type Room {
id: ID!
name: String!
description: String
createdAt: AWSDateTime!
messages(limit: Int, nextToken: String): MessageConnection
}
type MessageConnection {
items: [Message]
nextToken: String
}
type RoomConnection {
items: [Room]
nextToken: String
}
input CreateMessageInput {
roomId: ID!
content: String!
authorId: String!
authorName: String!
}
input CreateRoomInput {
name: String!
description: String
}
type Query {
getRoom(id: ID!): Room
listRooms(limit: Int, nextToken: String): RoomConnection
getMessagesForRoom(roomId: ID!, limit: Int, nextToken: String): MessageConnection
}
type Mutation {
createMessage(input: CreateMessageInput!): Message
createRoom(input: CreateRoomInput!): Room
deleteMessage(id: ID!, roomId: ID!): Message
}
type Subscription {
# Subscribe to all new messages in a specific room
onCreateMessage(roomId: ID!): Message
@aws_subscribe(mutations: ["createMessage"])
# Subscribe to messages from a specific author
onCreateMessageByAuthor(authorId: String!): Message
@aws_subscribe(mutations: ["createMessage"])
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
AppSync scalar types extend standard GraphQL: AWSDateTime, AWSDate, AWSTime, AWSTimestamp, AWSEmail, AWSJSON, AWSURL, and AWSIPAddress are all built in and validated automatically during request parsing.
LastEvaluatedKey. AppSync serializes this as a Base64-encoded nextToken string, making it easy to pass to subsequent queries for paginated list views.
Resolvers: VTL vs JavaScript
AppSync supports two resolver runtimes. The original VTL (Velocity Template Language) runtime uses Apache Velocity syntax to transform request and response payloads. The newer APPSYNC_JS (JavaScript) runtime introduced in 2022 lets you write resolvers as ES modules using a subset of JavaScript — no Node.js runtime, no npm packages, but full JavaScript syntax for control flow and data transformation.
VTL Resolver Structure
A VTL resolver consists of two templates: a request mapping template that transforms the GraphQL arguments into a data source operation, and a response mapping template that transforms the data source result into the GraphQL type.
JavaScript Pipeline Resolver
JavaScript resolvers use a pipeline model with explicit request and response functions exported from an ES module. Pipeline resolvers chain multiple functions, enabling you to compose reusable authorization checks and data transformations. Below is a JavaScript resolver for the createMessage mutation:
// Resolver: Mutation.createMessage (APPSYNC_JS runtime)
import { util } from '@aws-appsync/utils';
import { put } from '@aws-appsync/utils/dynamodb';
export function request(ctx) {
const { roomId, content, authorId, authorName } = ctx.args.input;
// Reject empty content at the resolver level — no Lambda needed
if (!content || content.trim().length === 0) {
util.error('Message content cannot be empty', 'ValidationError');
}
const id = util.autoId(); // generates a UUIDv4
const now = util.time.nowISO8601(); // AWSDateTime string
return put({
key: { id },
item: {
id,
roomId,
content: content.trim(),
authorId,
authorName,
createdAt: now,
updatedAt: now,
},
condition: { id: { attributeExists: false } }, // prevent overwrites
});
}
export function response(ctx) {
if (ctx.error) {
util.error(ctx.error.message, ctx.error.type);
}
return ctx.result;
}
for loops, array methods, ternary expressions, and helper utilities from @aws-appsync/utils. For any resolver with meaningful business logic, JavaScript is the better choice.
DynamoDB Resolvers with VTL Mapping Templates
VTL remains important because most existing AppSync APIs use it, and it is still the only option for certain legacy configurations. Understanding VTL mapping templates is essential for maintaining production APIs and for migrating them to the JavaScript runtime.
GetItem — Fetch a Single Room
## Request mapping template: Query.getRoom
{
"version": "2018-05-29",
"operation": "GetItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($ctx.args.id)
}
}
## Response mapping template: Query.getRoom
#if($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)
PutItem — Create a Room
## Request mapping template: Mutation.createRoom
{
"version": "2018-05-29",
"operation": "PutItem",
"key": {
"id": { "S": "$util.autoId()" }
},
"attributeValues": {
"name": $util.dynamodb.toDynamoDBJson($ctx.args.input.name),
"description": $util.dynamodb.toDynamoDBJson($util.defaultIfNullOrBlank($ctx.args.input.description, "")),
"createdAt": { "S": "$util.time.nowISO8601()" }
},
"condition": {
"expression": "attribute_not_exists(id)"
}
}
Query — List Messages for a Room
Messages are stored in a DynamoDB table with roomId as the partition key and createdAt as the sort key in a Global Secondary Index (GSI). The Query operation targets that GSI:
## Request mapping template: Query.getMessagesForRoom
{
"version": "2018-05-29",
"operation": "Query",
"index": "roomId-createdAt-index",
"query": {
"expression": "roomId = :roomId",
"expressionValues": {
":roomId": $util.dynamodb.toDynamoDBJson($ctx.args.roomId)
}
},
"scanIndexForward": false,
"limit": $util.defaultIfNull($ctx.args.limit, 20),
#if($ctx.args.nextToken)
"nextToken": "$ctx.args.nextToken",
#end
}
## Response mapping template: Query.getMessagesForRoom
#if($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
{
"items": $util.toJson($ctx.result.items),
"nextToken": $util.toJson($ctx.result.nextToken)
}
Real-Time Subscriptions
AppSync subscriptions work over persistent MQTT-over-WebSocket connections managed by the service. When a client executes a subscription query, AppSync registers a subscription filter. When any mutation tagged with @aws_subscribe completes successfully, AppSync evaluates all registered filters and pushes the mutation result to matching subscribers within 100–200 milliseconds.
Client Subscription Query
A frontend using Amplify libraries or the raw AppSync WebSocket protocol would send this subscription document:
# Subscribe to new messages in room "room-abc"
subscription OnRoomMessages {
onCreateMessage(roomId: "room-abc") {
id
content
authorName
createdAt
}
}
# Trigger the subscription by running this mutation (from another client)
mutation SendMessage {
createMessage(input: {
roomId: "room-abc"
content: "Hello, AppSync!"
authorId: "user-123"
authorName: "Alice"
}) {
id
content
createdAt
}
}
When createMessage completes, AppSync broadcasts the result to every client subscribed to onCreateMessage(roomId: "room-abc"). Clients subscribed with a different roomId argument do not receive the event — AppSync performs argument-based filtering server-side.
Enhanced Subscription Filtering
For more complex filtering logic than simple argument equality, AppSync supports enhanced subscription filters defined in the resolver. This lets you filter on nested fields, multiple conditions, or dynamic values resolved at subscription time:
// JavaScript resolver attached to Subscription.onCreateMessage
import { extensions } from '@aws-appsync/utils';
export function request(ctx) {
// No data source call needed — subscriptions resolve synchronously
return {};
}
export function response(ctx) {
const filter = {
// Only deliver events where roomId matches AND content is not empty
roomId: { eq: ctx.args.roomId },
content: { ne: '' },
};
extensions.setSubscriptionFilter(util.transform.toSubscriptionFilter(filter));
return null;
}
Authentication Modes
AppSync supports five authentication modes. You designate one as the default and optionally enable additional modes — a single API can require Cognito for mutations while allowing API key access for public read operations.
1. API Key
The simplest mode. AppSync generates a key that expires after 1–365 days. Pass it in the x-api-key HTTP header or the WebSocket connection URL. Use only for public read-only APIs or internal development — API keys in client-side JavaScript are visible to anyone who inspects network traffic.
2. Amazon Cognito User Pools
The standard choice for user-facing applications. The client obtains a JWT from Cognito after authentication and passes it as a Bearer token. AppSync validates the JWT signature against the Cognito JWKS endpoint. You can restrict fields per Cognito group:
# Schema — only admin group can delete messages
type Mutation {
deleteMessage(id: ID!, roomId: ID!): Message
@aws_auth(cognito_groups: ["Admins"])
createMessage(input: CreateMessageInput!): Message
@aws_cognito_user_pools # any authenticated user
}
3. AWS IAM
Used for server-to-server communication and AWS service integrations. The caller signs requests with SigV4 using an IAM role. AppSync evaluates IAM policies to authorize the operation. The Amplify library supports IAM auth for guest (unauthenticated) Cognito Identity Pool credentials, enabling public read access with usage tracking.
4. OpenID Connect (OIDC)
Use when your identity provider is not Cognito — Auth0, Okta, Ping Identity, or any OIDC-compliant provider. Configure the OIDC issuer URL and optional TTL values. AppSync validates JWTs against the provider's discovery endpoint. All standard JWT claims are available in resolver context via $ctx.identity.
5. Lambda Authorizer
The most flexible option. AppSync invokes a Lambda function for every request (with optional caching by token value). The function receives the authorization token and request context, then returns an authorization decision with optional resolver context. Use this for custom token formats, database-backed permission checks, or legacy authentication systems that predate OIDC:
# Lambda authorizer response format
{
"isAuthorized": true,
"resolverContext": {
"userId": "user-456",
"tenantId": "tenant-xyz",
"permissions": ["read:messages", "write:messages"]
},
"deniedFields": [],
"ttlOverride": 300 # cache this decision for 5 minutes
}
@aws_api_key, @aws_cognito_user_pools, @aws_iam, or @aws_oidc directives. Fields without a directive inherit the default auth mode.
Offline Sync and Conflict Resolution
One of AppSync's most differentiated features is built-in support for offline-first applications via Delta Sync. Mobile and web clients cache data locally (through Amplify DataStore or custom implementations) and continue reading and writing while offline. When connectivity resumes, the client syncs changes back to AppSync, which runs configurable conflict detection and resolution before writing to DynamoDB.
Versioned Conflict Detection
Enable versioning on the DynamoDB data source in the AppSync console. AppSync automatically adds a _version attribute to every item and increments it on each successful write. When a client submits an update, it includes the last known version:
mutation UpdateMessage($id: ID!, $content: String!, $version: Int!) {
updateMessage(input: {
id: $id
content: $content
_version: $version
}) {
id
content
_version
_lastChangedAt
}
}
If the stored version differs from the submitted version, a conflict has occurred. AppSync applies the configured resolution strategy:
- OPTIMISTIC_CONCURRENCY — Reject the update, return the current server version to the client. The client decides how to merge.
- AUTOMERGE — Available with Amplify DataStore. AppSync attempts a field-level merge using operation metadata stored in a sync table.
- LAMBDA — Pass both the server record and client record to a Lambda function that returns the winning version. Use this for domain-specific merge logic.
Delta Sync Base and Delta Tables
Delta Sync uses two DynamoDB tables: a base table containing the current state of all records, and a delta table storing a time-windowed log of mutations. A client that has been offline for under the delta window (default 30 days) downloads only the delta records instead of re-fetching the entire dataset. This dramatically reduces data transfer for large datasets on reconnection.
AppSync vs API Gateway + Lambda
Both services can expose serverless APIs, but they solve different problems. Choose based on your data access patterns and client requirements.
| Dimension | AppSync (GraphQL) | API Gateway + Lambda (REST/HTTP) |
|---|---|---|
| Protocol | GraphQL over HTTP + WebSocket | REST or HTTP over HTTPS |
| Real-time push | Built-in subscriptions (WebSocket) | Requires WebSocket API + custom routing logic |
| Flexible data fetching | Client specifies exact fields | Fixed response shape per endpoint |
| DynamoDB direct access | VTL / JS resolvers — no Lambda | Always through Lambda function |
| Offline sync | Delta Sync, conflict resolution built in | Custom implementation required |
| Latency (simple CRUD) | ~5 ms (direct DynamoDB resolver) | 15–50 ms (Lambda cold start + DynamoDB) |
| Complexity | Schema + resolver per field | Simpler for small, well-defined endpoints |
| Best for | Mobile/web apps, real-time feeds, offline-first | Microservices, webhooks, complex business logic |
Use AppSync when: clients are mobile or single-page apps with varying data needs, you need real-time feeds (chat, notifications, live dashboards), or you are building an offline-first mobile app with Amplify DataStore.
Use API Gateway + Lambda when: you are building backend-to-backend integrations, the API surface is small and stable, you need REST semantics for third-party consumers, or your business logic is too complex for resolver functions.
Creating an AppSync API with AWS CLI and boto3
The AWS CLI and boto3 let you automate AppSync API provisioning in CI/CD pipelines. Here is the complete sequence: create the API, attach a DynamoDB data source, upload the schema, and create a resolver.
# 1. Create the AppSync GraphQL API (Cognito User Pools auth)
aws appsync create-graphql-api \
--name "ChatAPI" \
--authentication-type AMAZON_COGNITO_USER_POOLS \
--user-pool-config \
userPoolId=us-east-1_XXXXXXXXX,awsRegion=us-east-1,defaultAction=ALLOW \
--region us-east-1
# Save the returned apiId
API_ID="abcdef1234567890"
# 2. Attach a DynamoDB data source
aws appsync create-data-source \
--api-id $API_ID \
--name "MessagesTable" \
--type AMAZON_DYNAMODB \
--dynamodb-config tableName=Messages,awsRegion=us-east-1,useCallerCredentials=false \
--service-role-arn arn:aws:iam::123456789012:role/AppSyncDynamoDBRole
# 3. Upload the schema (schema.graphql must exist locally)
aws appsync start-schema-creation \
--api-id $API_ID \
--definition fileb://schema.graphql
# Poll until schema creation is done
aws appsync get-schema-creation-status --api-id $API_ID
The same operations in Python with boto3, including creating a JavaScript resolver:
import boto3
import json
appsync = boto3.client('appsync', region_name='us-east-1')
API_ID = 'abcdef1234567890'
# Create a JavaScript resolver for Mutation.createMessage
resolver_code = """
import { util } from '@aws-appsync/utils';
import { put } from '@aws-appsync/utils/dynamodb';
export function request(ctx) {
const { roomId, content, authorId, authorName } = ctx.args.input;
if (!content || content.trim().length === 0) {
util.error('Content cannot be empty', 'ValidationError');
}
const id = util.autoId();
const now = util.time.nowISO8601();
return put({
key: { id },
item: { id, roomId, content: content.trim(), authorId, authorName,
createdAt: now, updatedAt: now },
condition: { id: { attributeExists: false } },
});
}
export function response(ctx) {
if (ctx.error) { util.error(ctx.error.message, ctx.error.type); }
return ctx.result;
}
"""
response = appsync.create_resolver(
apiId=API_ID,
typeName='Mutation',
fieldName='createMessage',
dataSourceName='MessagesTable',
kind='UNIT',
runtime={
'name': 'APPSYNC_JS',
'runtimeVersion': '1.0.0',
},
code=resolver_code,
)
print(f"Resolver created: {response['resolver']['resolverArn']}")
# Retrieve an API key (if API key auth is enabled as secondary)
key_response = appsync.create_api_key(
apiId=API_ID,
description='Development API key',
expires=1780000000, # Unix timestamp — set to desired expiry
)
print(f"API Key: {key_response['apiKey']['id']}")
dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem, dynamodb:Query, and dynamodb:Scan on the target table and its GSIs. Scope the resource ARN to the specific table rather than using a wildcard.
Frequently Asked Questions
Can AppSync call multiple data sources in a single resolver?
Yes — this is the purpose of pipeline resolvers. A pipeline resolver chains two or more functions, each attached to a different data source. For example, the first function checks a permissions table in DynamoDB, and the second function fetches the actual data from a different table. Each function sees the output of the previous one through the ctx.prev.result context variable.
How does AppSync handle N+1 query problems?
AppSync supports batch resolvers for DynamoDB using the BatchGetItem operation. When a list query returns 20 rooms and each room resolver needs to fetch messages, AppSync can batch all 20 message fetches into a single DynamoDB BatchGetItem call. Configure this in the resolver by setting operation to BatchGetItem in the VTL template or using batchGet from @aws-appsync/utils/dynamodb in JavaScript.
What is the maximum subscription connection limit?
AppSync supports up to 1,000,000 concurrent WebSocket connections per API by default. You can request a limit increase through AWS Support. Each connection counts toward your bill at the connection-minute rate. Connections idle for more than 5 minutes receive a keep-alive ping; connections idle for more than 2 hours are disconnected by the service.
Can I use AppSync with a custom domain?
Yes. AppSync supports custom domain names through the AppSync console or API. Create a domain association pointing to your AppSync API, then create a CNAME in Route 53 pointing your domain to the AppSync-provided CloudFront endpoint. Custom domains work for both HTTP and WebSocket (subscription) traffic.
How do I test AppSync subscriptions locally?
Use the AWS AppSync console's built-in query editor — it supports subscriptions over WebSocket in the browser. For automated testing, the graphql-ws npm package can connect to AppSync WebSocket endpoints with the required headers. Amplify's API.graphql method handles auth header injection automatically in JavaScript applications.