Skip to content
Ayhan Sipahi Ayhan Sipahi

AWS AppSync & GraphQL: Building Production-Ready Real-time APIs

Building scalable real-time APIs with AWS AppSync: JavaScript resolvers, subscription filtering, caching strategies, and infrastructure as code patterns.

AWS AppSync gives you a managed GraphQL endpoint with WebSocket subscriptions, conflict resolution, and direct connections to AWS data sources. For most teams the default that holds is a narrow one: wire resolvers straight to DynamoDB with the JavaScript (APPSYNC_JS) runtime, and reach for Lambda only when a field needs async I/O or logic the runtime cannot express.

That single choice moves latency and cost further than any schema decision does. Subscription filtering, caching layers, single-table versus multi-table modeling, and the CDK wiring around them are all tuning on top of it.

The Hard Parts of Real-time APIs

Building modern applications with real-time features presents several technical challenges that extend beyond simple REST API development:

Infrastructure complexity: Managing WebSocket servers requires handling connection state, scaling bidirectional communication, and ensuring high availability. Traditional approaches involve deploying socket.io servers or maintaining Redis pub/sub infrastructure.

Data synchronization: Keeping data consistent across multiple clients becomes exponentially complex when users go offline and come back online with pending changes. The N-client problem means potential conflicts multiply with each additional user.

Fine-grained authorization: REST APIs typically authorize at the endpoint level, but GraphQL requires field-level access control. A single query might request data with different permission requirements across nested fields.

Performance vs cost trade-offs: Real-time features can drive unexpected costs through long-lived WebSocket connections, high-frequency subscription updates, and inefficient resolver implementations.

Here’s what a typical request flow looks like in AppSync:

DynamoDBDataSourceResolverAppSyncClientDynamoDBDataSourceResolverAppSyncClientGraphQL QueryExecute ResolverTransform RequestDynamoDB QueryReturn DataRaw ResultTransform ResponseGraphQL Response

Technical Requirements

A production-ready real-time GraphQL API needs to address these technical requirements:

Resolver performance: Choose between JavaScript resolvers, VTL (Velocity Template Language), pipeline resolvers, and direct Lambda integration. Each approach has different latency characteristics and development complexity.

Subscription architecture: Implement server-side filtering to reduce client bandwidth and processing overhead. Distinguish between traditional mutation-based subscriptions and the newer AppSync Events channel-based approach.

Caching layers: Evaluate AppSync’s built-in ElastiCache integration, DynamoDB as a long-term cache, and DAX (DynamoDB Accelerator) for different access patterns and TTL requirements.

Data modeling strategy: Decide between single-table and multi-table DynamoDB designs based on access patterns. The GraphQL schema structure doesn’t need to mirror the database structure; this flexibility is both powerful and potentially problematic.

Authorization configuration: Set up multi-auth modes (API Key, Cognito User Pools, IAM, OIDC, Lambda authorizers) with field-level directives for granular access control.

Implementation

Understanding AppSync Architecture

AppSync sits between clients and data sources, providing a managed GraphQL endpoint with integrated WebSocket support for subscriptions. It can connect directly to AWS data sources without a Lambda in between, and most of the latency and cost argument rests on that one property:

import * as appsync from 'aws-cdk-lib/aws-appsync';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';

export class AppSyncApiStack extends Construct {
  public readonly api: appsync.GraphqlApi;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Create GraphQL API with multi-auth configuration
    this.api = new appsync.GraphqlApi(this, 'Api', {
      name: 'production-api',
      definition: appsync.Definition.fromFile('schema.graphql'),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: {
            userPool: userPool,
          },
        },
        additionalAuthorizationModes: [
          { authorizationType: appsync.AuthorizationType.IAM },
          { authorizationType: appsync.AuthorizationType.API_KEY },
        ],
      },
      xrayEnabled: true,
      logConfig: {
        fieldLogLevel: appsync.FieldLogLevel.ALL,
        excludeVerboseContent: false,
      },
    });

    // Create DynamoDB table with streams for real-time updates
    const table = new dynamodb.Table(this, 'DataTable', {
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
      pointInTimeRecovery: true,
    });

    // Direct DynamoDB data source (no Lambda)
    const dataSource = this.api.addDynamoDbDataSource('MainDataSource', table);
  }
}

The direct data source connection eliminates Lambda invocation costs and cold start latency. For simple CRUD operations the request never leaves the AppSync runtime, so there is no init phase to pay for and no second billing surface to tune.

Modern JavaScript Resolvers

AppSync now supports JavaScript resolvers as the recommended approach over VTL. Here’s a practical comparison using a common DynamoDB query operation:

Legacy VTL approach (harder to maintain):

{
  "version": "2018-05-29",
  "operation": "Query",
  "query": {
    "expression": "PK = :pk AND begins_with(SK, :sk)",
    "expressionValues": {
      ":pk": $util.dynamodb.toDynamoDBJson($ctx.args.userId),
      ":sk": $util.dynamodb.toDynamoDBJson("ORDER#")
    }
  },
  "index": "GSI1",
  "limit": $util.defaultIfNull($ctx.args.limit, 20),
  "nextToken": $util.toJson($ctx.args.nextToken)
}

Modern JavaScript approach (better developer experience):

// resolvers/getUserOrders.js
import * as ddb from '@aws-appsync/utils/dynamodb';

export function request(ctx) {
  const { userId, limit = 20, nextToken } = ctx.args;

  return ddb.query({
    query: {
      PK: { eq: userId },
      SK: { beginsWith: 'ORDER#' },
    },
    index: 'GSI1',
    limit,
    nextToken,
  });
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }

  return {
    items: ctx.result.items,
    nextToken: ctx.result.nextToken,
  };
}

Important limitations of JavaScript resolvers:

  • No async/await support (APPSYNC_JS runtime restriction)
  • No traditional for loops (use for-in, for-of, or array methods)
  • No try/catch blocks (use early returns and explicit error handling)
  • ECMAScript 6 subset only

For complex async operations, use pipeline resolvers with a Lambda function step, or direct Lambda resolvers.

Pipeline Resolvers for Multi-Step Operations

Pipeline resolvers allow composing multiple operations without additional Lambda invocations. This pattern works well for authorization checks, quota enforcement, and data transformations:

// Function 1: Check user quota
export function request(ctx) {
  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ userId: ctx.identity.sub }),
  };
}

export function response(ctx) {
  const quota = ctx.result?.quota ?? 0;

  if (quota <= 0) {
    util.error('API quota exceeded', 'QuotaExceeded');
  }

  // Pass quota info to next function via stash
  ctx.stash.currentQuota = quota;
  return ctx.result;
}
// Function 2: Fetch requested data
export function request(ctx) {
  return {
    operation: 'Query',
    query: {
      expression: 'PK = :pk',
      expressionValues: {
        ':pk': util.dynamodb.toDynamoDB(ctx.args.id),
      },
    },
  };
}

export function response(ctx) {
  // Pass data to next function
  ctx.stash.data = ctx.result.items;
  return ctx.result;
}
// Function 3: Update quota counter
export function request(ctx) {
  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ userId: ctx.identity.sub }),
    update: {
      expression: 'SET quota = quota - :decrement',
      expressionValues: {
        ':decrement': { N: 1 },
      },
    },
  };
}

export function response(ctx) {
  // Return the data from Function 2
  return ctx.stash.data;
}

The ctx.stash object allows passing data between pipeline functions without modifying the actual response until the final function.

Real-time Subscriptions with Enhanced Filtering

Traditional GraphQL subscriptions trigger on mutations, but clients often need to filter which updates they receive. AppSync’s enhanced filtering performs this server-side:

GraphQL schema:

type Subscription {
  onMessagePosted(roomId: ID!): Message
    @aws_subscribe(mutations: ["postMessage"])
}

type Mutation {
  postMessage(roomId: ID!, content: String!, userId: ID!): Message
}

type Message {
  id: ID!
  roomId: ID!
  userId: ID!
  content: String!
  timestamp: AWSDateTime!
}

Subscription resolver with enhanced filtering:

// resolvers/onMessagePosted.js
export function request(ctx) {
  return { payload: null };
}

export function response(ctx) {
  // Set server-side subscription filter
  const filter = {
    filterGroup: [
      {
        filters: [
          // Only messages for this room
          {
            fieldName: 'roomId',
            operator: 'eq',
            value: ctx.args.roomId,
          },
          // Don't send to the message author
          {
            fieldName: 'userId',
            operator: 'ne',
            value: ctx.identity.sub,
          },
        ],
      },
    ],
  };

  extensions.setSubscriptionFilter(util.transform.toSubscriptionFilter(filter));
  return null;
}

Available filter operators include: eq, ne, in, notIn, gt, ge, lt, le, between, contains, notContains, beginsWith, containsAny. Filters within a group use AND logic; multiple groups use OR logic.

Filtering server-side matters most in multi-tenant apps, where one client subscribes to many rooms. Without it, every client receives every message the mutation produces and discards most of them after the payload has already crossed the network.

AppSync Events: Channel-Based Real-time

AppSync Events provides a newer, more flexible approach to real-time updates, decoupled from GraphQL mutations:

Publish

Filter

Authorize

Authorize

Wildcard

Publisher Lambda/HTTP

AppSync Channel

OnPublish Handler

Client 1

Client 2

Admin Client

Key differences from traditional subscriptions:

FeatureTraditional SubscriptionsAppSync Events
TriggerGraphQL mutationsHTTP/WebSocket publish
Schema couplingTight (mutation-based)Loose (channel-based)
FilteringField-based filtersCustom handlers
WildcardsNot supportednamespace/channel/*
AuthorizationGraphQL directivesOnPublish/OnSubscribe handlers

Use case example: IoT sensor data where devices publish via HTTP but clients subscribe via WebSocket:

// Lambda function publishes to AppSync Events channel via HTTP
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';

export async function handler(event) {
  // IoT sensor sends data
  const sensorData = JSON.parse(event.body);

  const endpoint = `https://${process.env.APPSYNC_API_ID}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`;
  const payload = JSON.stringify({
    channel: `device/${sensorData.deviceId}`,
    events: [JSON.stringify(sensorData)],
  });

  // Sign the request with SigV4
  const signer = new SignatureV4({
    credentials: await import('@aws-sdk/credential-provider-node').then(m => m.defaultProvider()()),
    region: process.env.AWS_REGION,
    service: 'appsync',
    sha256: Sha256,
  });

  const signedRequest = await signer.sign({
    method: 'POST',
    hostname: `${process.env.APPSYNC_API_ID}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`,
    path: `/event`,
    protocol: 'https:',
    headers: {
      'Content-Type': 'application/json',
      host: `${process.env.APPSYNC_API_ID}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`,
    },
    body: payload,
  });

  const response = await fetch(`${endpoint}/event`, {
    method: 'POST',
    headers: signedRequest.headers,
    body: payload,
  });

  return { statusCode: response.status };
}

Client subscribes to specific device or all devices:

subscription OnSensorData {
  subscribe(namespace: "sensors", channel: "device/sensor-123") {
    id
    data
  }
}

subscription OnAllSensors {
  subscribe(namespace: "sensors", channel: "device/*") {
    id
    data
  }
}

Caching Strategies

AppSync provides built-in caching via ElastiCache, but choosing the right caching strategy depends on data freshness requirements and cost constraints.

High

Low

Seconds

Minutes to Hours

Microseconds

Cache Strategy Decision

Access Frequency

Freshness Required

No Cache Needed

AppSync Cache TTL 1-3600s

DynamoDB Cache Table

DynamoDB DAX

$$$ ElastiCache Hourly Cost

$ Pay-per-request

$$ DAX Hourly Cost

AppSync built-in cache configuration:

// CDK configuration
const resolver = dataSource.createResolver('GetProduct', {
  typeName: 'Query',
  fieldName: 'getProduct',
  code: appsync.Code.fromAsset('resolvers/getProduct.js'),
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  cachingConfig: {
    ttl: Duration.minutes(5),
    cachingKeys: ['$context.identity.sub', '$context.arguments.id'],
  },
});

The cachingKeys list decides how the cache partitions. Including $context.identity.sub gives every user a private entry, which is correct for personalized data but multiplies the entry count and drags the hit rate down. Leave it out for shared reference data such as a product catalog.

DynamoDB as long-term cache (pipeline resolver pattern):

// Function 1: Check cache table
export function request(ctx) {
  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ cacheKey: ctx.args.id }),
  };
}

export function response(ctx) {
  const cached = ctx.result;
  const now = util.time.nowEpochSeconds();

  // Check if cache is valid
  if (cached && cached.ttl > now) {
    // Return cached data, skip remaining functions
    return JSON.parse(cached.data);
  }

  // Cache miss, continue to next function
  return null;
}
// Function 2: Fetch from expensive source (external API, complex query)
// Function 3: Store result in cache table with TTL attribute
export function request(ctx) {
  const ttl = util.time.nowEpochSeconds() + 3600; // 1 hour

  return {
    operation: 'PutItem',
    key: util.dynamodb.toMapValues({ cacheKey: ctx.args.id }),
    attributeValues: util.dynamodb.toMapValues({
      data: JSON.stringify(ctx.prev.result),
      ttl: ttl,
    }),
  };
}

export function response(ctx) {
  return ctx.prev.result; // Return data from Function 2
}

Enable DynamoDB TTL on the ttl attribute to automatically delete expired cache entries.

Schema Design: Single-table vs Multi-table

The choice between single-table and multi-table DynamoDB design significantly impacts resolver complexity and query performance.

Multi-table design (simpler resolvers, more flexibility):

UsersTable: PK=userId
ProductsTable: PK=productId
OrdersTable: PK=orderId, GSI: userId-timestamp

GraphQL resolver for user with orders requires two queries:

// getUser resolver
export function request(ctx) {
  return { operation: 'GetItem', key: { id: ctx.args.userId } };
}

// user.orders resolver (separate resolver)
export function request(ctx) {
  return {
    operation: 'Query',
    index: 'userIdIndex',
    query: {
      userId: { eq: ctx.source.id },
    },
  };
}

Single-table design (complex resolvers, optimized queries):

MainTable:
PK=USER#123, SK=PROFILE
PK=USER#123, SK=ORDER#2024-12-01#001
PK=USER#123, SK=ORDER#2024-11-30#002
PK=PRODUCT#789, SK=METADATA

Single query fetches user and orders:

export function request(ctx) {
  return {
    operation: 'Query',
    query: {
      PK: { eq: `USER#${ctx.args.userId}` },
    },
  };
}

export function response(ctx) {
  const items = ctx.result.items;

  // Separate profile from orders
  const profile = items.find(item => item.SK === 'PROFILE');
  const orders = items.filter(item => item.SK.startsWith('ORDER#'));

  return {
    ...profile,
    orders: orders,
  };
}

When to use each approach:

  • Multi-table: Prototyping, evolving schemas, unknown access patterns, small-to-medium scale
  • Single-table: Known access patterns, high scale requirements, latency-critical applications, cost optimization

Authorization Modes

AppSync supports five authorization modes that can be combined in a single API:

type Query {
  # Public data accessible with API key
  publicPosts: [Post] @aws_api_key

  # Authenticated users only
  myPosts: [Post] @aws_cognito_user_pools

  # Admin users only
  allUsers: [User] @aws_cognito_user_pools(cognito_groups: ["Admin"])

  # Service-to-service via IAM
  internalData: [Data] @aws_iam

  # Custom authorization logic
  partnerData: [Data] @aws_lambda
}

Lambda authorizer for custom logic (e.g., validating API keys stored in DynamoDB):

export async function handler(event: AppSyncAuthorizerEvent) {
  const apiKey = event.authorizationToken;

  // Look up API key in DynamoDB
  const result = await dynamodb.get({
    TableName: 'ApiKeys',
    Key: { apiKey },
  });

  if (!result.Item || result.Item.expiresAt < Date.now()) {
    return {
      isAuthorized: false,
      deniedFields: ['Query.*'],
    };
  }

  return {
    isAuthorized: true,
    resolverContext: {
      customerId: result.Item.customerId,
      tier: result.Item.tier,
    },
    ttlOverride: 300, // Cache authorization result for 5 minutes
  };
}

The resolverContext is accessible in resolvers via ctx.identity.resolverContext, allowing custom authorization data to flow through the request.

Conflict Resolution for Offline Support

When building offline-first applications, handling concurrent updates requires a conflict resolution strategy. AppSync supports three approaches:

1. Optimistic Concurrency (version checking):

// Mutation resolver with version check
export function request(ctx) {
  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ id: ctx.args.id }),
    update: {
      expression: 'SET #content = :content, #version = :newVersion',
      expressionNames: {
        '#content': 'content',
        '#version': 'version',
      },
      expressionValues: {
        ':content': util.dynamodb.toDynamoDB(ctx.args.content),
        ':newVersion': util.dynamodb.toDynamoDB(ctx.args.version + 1),
        ':expectedVersion': util.dynamodb.toDynamoDB(ctx.args.version),
      },
    },
    condition: {
      expression: '#version = :expectedVersion',
      expressionNames: { '#version': 'version' },
    },
  };
}

export function response(ctx) {
  if (ctx.error) {
    // Version mismatch - conflict detected
    if (ctx.error.type === 'DynamoDB:ConditionalCheckFailedException') {
      util.error('Conflict: Item was modified by another user', 'ConflictError', ctx.result);
    }
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result;
}

2. Automerge (default for Amplify DataStore):

  • Automatically merges non-conflicting field changes
  • Collections use set union
  • Scalars use last-writer-wins

3. Custom Lambda resolver:

export async function handler(event: ConflictEvent) {
  const { base, local, remote } = event;

  // Custom merge logic
  const resolved = {
    ...base,
    // Prefer local edits for content
    content: local.content,
    // Sum numeric values
    viewCount: (local.viewCount || 0) + (remote.viewCount || 0) - (base.viewCount || 0),
    // Merge arrays
    tags: [...new Set([...local.tags, ...remote.tags])],
  };

  return resolved;
}

Delta Sync for efficient synchronization:

AppSync can track changes in a separate Delta Sync table, allowing clients to request only items modified since their last sync:

query SyncPosts($lastSync: AWSTimestamp!) {
  syncPosts(lastSync: $lastSync, limit: 100) {
    items {
      id
      content
      updatedAt
      _deleted
    }
    nextToken
  }
}

Complete CDK Infrastructure Example

Here’s a production-ready AppSync API with TypeScript resolver bundling:

import * as cdk from 'aws-cdk-lib';
import * as appsync from 'aws-cdk-lib/aws-appsync';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import { execSync } from 'child_process';

export class ProductionAppSyncStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Build TypeScript resolvers to JavaScript
    execSync('npm run build:resolvers', {
      cwd: './resolvers',
      stdio: 'inherit',
    });

    // Cognito User Pool for authentication
    const userPool = new cognito.UserPool(this, 'UserPool', {
      selfSignUpEnabled: true,
      userVerification: {
        emailSubject: 'Verify your email',
        emailBody: 'Verification code: {####}',
      },
      signInAliases: { email: true },
      passwordPolicy: {
        minLength: 8,
        requireLowercase: true,
        requireUppercase: true,
        requireDigits: true,
      },
    });

    // DynamoDB table with single-table design
    const table = new dynamodb.Table(this, 'MainTable', {
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
      pointInTimeRecovery: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      // Enable TTL for cache entries
      timeToLiveAttribute: 'ttl',
    });

    // GSI for user-specific queries
    table.addGlobalSecondaryIndex({
      indexName: 'GSI1',
      partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
      projectionType: dynamodb.ProjectionType.ALL,
    });

    // CloudWatch log group for API logs
    const logGroup = new logs.LogGroup(this, 'ApiLogs', {
      retention: logs.RetentionDays.ONE_WEEK,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // AppSync GraphQL API
    const api = new appsync.GraphqlApi(this, 'Api', {
      name: `${id}-api`,
      definition: appsync.Definition.fromFile('schema.graphql'),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: { userPool },
        },
        additionalAuthorizationModes: [
          { authorizationType: appsync.AuthorizationType.IAM },
          {
            authorizationType: appsync.AuthorizationType.API_KEY,
            apiKeyConfig: {
              expires: cdk.Expiration.after(cdk.Duration.days(365)),
            },
          },
        ],
      },
      xrayEnabled: true,
      logConfig: {
        fieldLogLevel: appsync.FieldLogLevel.ALL,
        excludeVerboseContent: false,
        cloudWatchLogsLogGroup: logGroup,
      },
    });

    // DynamoDB data source
    const dataSource = api.addDynamoDbDataSource('MainDataSource', table);

    // Create resolvers from bundled JavaScript files
    const resolvers = [
      { typeName: 'Query', fieldName: 'getUser', file: 'getUser.js' },
      { typeName: 'Query', fieldName: 'listPosts', file: 'listPosts.js' },
      { typeName: 'Mutation', fieldName: 'createPost', file: 'createPost.js' },
      { typeName: 'Mutation', fieldName: 'updatePost', file: 'updatePost.js' },
    ];

    resolvers.forEach(({ typeName, fieldName, file }) => {
      dataSource.createResolver(`${typeName}${fieldName}Resolver`, {
        typeName,
        fieldName,
        code: appsync.Code.fromAsset(`resolvers/dist/${file}`),
        runtime: appsync.FunctionRuntime.JS_1_0_0,
      });
    });

    // Outputs
    new cdk.CfnOutput(this, 'GraphQLApiUrl', {
      value: api.graphqlUrl,
    });
    new cdk.CfnOutput(this, 'ApiKey', {
      value: api.apiKey || 'N/A',
    });
    new cdk.CfnOutput(this, 'UserPoolId', {
      value: userPool.userPoolId,
    });
  }
}

Resolver build script (resolvers/package.json):

{
  "scripts": {
    "build:resolvers": "esbuild src/*.ts --bundle --platform=node --target=es2020 --outdir=dist --format=esm"
  },
  "devDependencies": {
    "esbuild": "^0.19.0",
    "@aws-appsync/utils": "^1.3.0"
  }
}

Monitoring and Observability

Production AppSync APIs require comprehensive monitoring across multiple dimensions:

CloudWatch Metrics (automatic):

  • 4XXError and 5XXError: Client and server error rates
  • Latency: Request processing time (P50, P95, P99)
  • ConnectedSubscriptions: Active WebSocket connections
  • SubscriptionPublishErrors: Failed subscription deliveries

X-Ray tracing provides detailed request flow visualization:

// X-Ray shows:
// 1. AppSync API entry
// 2. Resolver execution time
// 3. DynamoDB query latency
// 4. Total request duration

Enable field-level logging to debug specific resolver issues:

logConfig: {
  fieldLogLevel: appsync.FieldLogLevel.ALL, // Logs each resolver execution
  excludeVerboseContent: false, // Include request/response bodies
}

Custom CloudWatch dashboard:

const dashboard = new cloudwatch.Dashboard(this, 'ApiDashboard', {
  dashboardName: 'AppSync-Production',
});

dashboard.addWidgets(
  new cloudwatch.GraphWidget({
    title: 'Request Latency',
    left: [
      api.metricLatency({ statistic: 'p50' }),
      api.metricLatency({ statistic: 'p95' }),
      api.metricLatency({ statistic: 'p99' }),
    ],
  }),
  new cloudwatch.GraphWidget({
    title: 'Error Rate',
    left: [
      api.metric4XXError(),
      api.metric5XXError(),
    ],
  }),
);

Choosing Between the Patterns

The pieces above collapse into a handful of decisions worth settling before the schema grows past a few dozen fields.

Resolver type comes first. JavaScript resolvers cover simple CRUD, pipeline resolvers cover multi-step work such as an authorization check followed by a fetch, and Lambda covers what the APPSYNC_JS runtime cannot express: async I/O, third-party SDKs, heavy business logic. Putting Lambda on a path that a direct DynamoDB resolver could serve buys an extra invocation to pay for and a cold start to explain.

Table layout is the decision that is hardest to reverse. Single-table pays off when the access patterns are already known and one query can fill a whole screen. Multi-table pays off while the schema is still moving, because each resolver stays independent. Going from multi-table to single-table mid-project means rewriting every resolver and backfilling every item, so make this call early rather than well.

Subscription filtering follows a simpler rule: any subscription with more than one consumer should filter server-side. Client-side filtering sends the same payload to every subscriber and lets the device throw it away, which shows up first on mobile networks and mobile batteries.

Caching splits by data shape. Reference data with high read frequency and rare writes fits the built-in AppSync cache, which is billed per hour whether or not it is hit. User-specific data that stays valid for hours fits a DynamoDB cache table with a TTL attribute, billed per request. DAX fits the case where the access is already a DynamoDB read and the target is microseconds.

Two costs tend to surprise people. Subscriptions bill per connection-minute as well as per message, so a mobile client holding its WebSocket open in the background accrues charges with nobody watching; disconnect on background, resubscribe on resume. And optimistic concurrency rejects conflicting writes rather than losing them, so the client needs a retry path for ConflictError before collaborative editing ships.

The default that holds for most APIs is the narrow one: direct data sources, JavaScript resolvers, Lambda only where the runtime genuinely blocks you. Override it when a field needs an async call, a third-party SDK, or error handling that early returns cannot express. That is where the extra invocation earns its cost.

References

Related posts