+1 (726) 207-9872

GraphQL on AWS: AppSync vs Lambda-Hosted Servers with Serverless Framework V4

Every serverless team that ships a REST API eventually gets the same request from the front-end: "can we get all of this in one call?" On a mobile client with a flaky connection, six round trips to six Lambda functions is a real user-experience problem. GraphQL solves it, and on AWS you have two very different ways to run it — AWS AppSync, or a single Lambda running a GraphQL server such as Apollo or GraphQL Yoga.

We have built and inherited both. This post covers when each one is the right call, and then walks through a production-shaped AppSync API deployed entirely from a Serverless Framework V4 service: schema, JavaScript resolvers over DynamoDB, Lambda resolvers for the things DynamoDB cannot do, Cognito authorization, subscriptions, and the N+1 trap that ruins most first attempts.

AppSync or Lambda-hosted GraphQL?

Both are legitimate. Pick on operational profile, not fashion.

AppSync is a managed GraphQL service. It parses the query, executes resolvers, and talks directly to DynamoDB, Aurora, OpenSearch, EventBridge, or any HTTP endpoint — often without a Lambda in the path at all. You get managed WebSocket subscriptions, per-resolver caching, and no cold starts on direct data-source resolvers.

  • Cost: billed per query and per real-time message; roughly $4 per million queries plus subscription message and connection-minute charges.
  • Best for: read-heavy APIs over DynamoDB, apps that need real-time subscriptions, teams that want the fan-out handled for them.
  • Friction: resolvers are APPSYNC_JS (a constrained JavaScript runtime — no async/await, no network calls, no npm packages) or VTL. Local testing is weaker than plain Node.

Lambda-hosted GraphQL (Apollo Server or Yoga behind one function) is just your normal Node.js code. You keep DataLoader, your existing middleware, your unit tests, and full npm access.

  • Cost: Lambda duration plus API Gateway requests — usually cheaper at high volume for compute-light queries.
  • Best for: complex business logic, federation with existing services, teams already deep in the Apollo ecosystem.
  • Friction: cold starts hit the whole API, not one field; subscriptions mean building your own WebSocket layer (see our post on WebSocket APIs and response streaming); one oversized function becomes a monolith.

A useful rule: if most fields are "fetch this item / list these items from DynamoDB", AppSync removes an enormous amount of code. If most fields are "call three internal services and apply pricing rules", host GraphQL in Lambda.

The rest of this post builds the AppSync version, because that is the one with the Serverless Framework wrinkles.

The service skeleton

Serverless Framework V4 does not have first-class AppSync syntax, so you have two options: the serverless-appsync-plugin, or raw CloudFormation in the resources block. The plugin is far more pleasant and is what we use on client work.

service: catalog-graphql

provider:
  name: aws
  runtime: nodejs22.x
  region: eu-west-1
  stage: ${opt:stage, 'dev'}

plugins:
  - serverless-appsync-plugin

custom:
  tableName: ${self:service}-${sls:stage}-items

appSync:
  name: ${self:service}-${sls:stage}
  authentication:
    type: AMAZON_COGNITO_USER_POOLS
    config:
      userPoolId: !Ref UserPool
      defaultAction: DENY
  additionalAuthentications:
    - type: AWS_IAM        # for service-to-service and admin tooling
  schema: schema.graphql
  logging:
    level: ERROR
    retentionInDays: 14
  xrayEnabled: true

defaultAction: DENY matters. With ALLOW, any authenticated Cognito user can reach any field you forgot to annotate. Deny by default and open fields explicitly.

The schema

type Item {
  id: ID!
  ownerId: ID!
  name: String!
  priceCents: Int!
  status: ItemStatus!
  reviews(limit: Int = 10, nextToken: String): ReviewConnection!
  createdAt: AWSDateTime!
}

enum ItemStatus { DRAFT ACTIVE ARCHIVED }

type Review {
  id: ID!
  itemId: ID!
  rating: Int!
  body: String
}

type ReviewConnection {
  items: [Review!]!
  nextToken: String
}

type Query {
  item(id: ID!): Item
  itemsByStatus(status: ItemStatus!, limit: Int = 20, nextToken: String): ItemConnection!
}

type Mutation {
  createItem(input: CreateItemInput!): Item! @aws_cognito_user_pools(cognito_groups: ["sellers"])
  archiveItem(id: ID!): Item!
}

type Subscription {
  onItemStatusChanged(id: ID!): Item
    @aws_subscribe(mutations: ["archiveItem"])
}

Two things to note. Field-level directives such as @aws_cognito_user_pools(cognito_groups: [...]) are how you do coarse authorization without writing code. And @aws_subscribe wires a subscription to the mutations that trigger it — AppSync publishes the mutation's return value to every subscriber whose arguments match.

JavaScript resolvers over DynamoDB

APPSYNC_JS resolvers export a request and a response function. They are synchronous, single-purpose, and run in a sandbox — which is exactly why they are fast and free of cold starts.

// resolvers/Query.item.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ pk: `ITEM#${ctx.args.id}`, sk: 'META' }),
  };
}

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

A paginated query resolver over a GSI:

// resolvers/Query.itemsByStatus.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const { status, limit, nextToken } = ctx.args;
  return {
    operation: 'Query',
    index: 'gsi1',
    query: {
      expression: '#pk = :pk',
      expressionNames: { '#pk': 'gsi1pk' },
      expressionValues: util.dynamodb.toMapValues({ ':pk': `STATUS#${status}` }),
    },
    limit: Math.min(limit ?? 20, 100),
    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 };
}

The access patterns and GSI layout come from the same single-table design work described in our DynamoDB single-table design guide — GraphQL does not change that, it just makes the hierarchy visible in the schema.

Wiring them up in serverless.yml:

appSync:
  dataSources:
    itemsTable:
      type: AMAZON_DYNAMODB
      config:
        tableName: ${self:custom.tableName}
    pricingFn:
      type: AWS_LAMBDA
      config:
        functionName: pricing

  resolvers:
    Query.item:
      dataSource: itemsTable
      code: resolvers/Query.item.js
    Query.itemsByStatus:
      dataSource: itemsTable
      code: resolvers/Query.itemsByStatus.js
    Item.reviews:
      dataSource: itemsTable
      code: resolvers/Item.reviews.js

Note there is no IAM policy to write by hand: the plugin grants the AppSync service role access to the data sources you declare. Keep that grant narrow anyway — scope the DynamoDB policy to the table and its indexes, following the same least-privilege approach as our IAM and secrets guide.

The N+1 problem, and BatchInvoke

Here is the failure mode every new GraphQL API hits. A client asks for 50 items and each item's seller:

query { itemsByStatus(status: ACTIVE, limit: 50) { items { id name seller { displayName } } } }

If Item.seller is a per-item resolver, AppSync executes it 50 times — 50 GetItem calls, or worse, 50 Lambda invocations. Latency goes up linearly and your bill follows.

Two fixes:

1. Lambda resolvers with batching. Set maxBatchSize and AppSync hands your function an array of contexts instead of one:

  resolvers:
    Item.seller:
      dataSource: sellersFn
      kind: UNIT
      maxBatchSize: 50
      code: resolvers/Item.seller.js
// resolvers/Item.seller.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  return { operation: 'BatchInvoke', payload: { sellerId: ctx.source.ownerId } };
}

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

Your Lambda receives [{ sellerId: 'a' }, { sellerId: 'b' }, ...], does one BatchGetItem, and returns results in the same order as the input array. One invocation instead of fifty.

Use util.appendError rather than util.error in batch responses: error fails the whole batch, append nulls one field and lets the rest of the response through.

2. Denormalize. If the seller display name rarely changes, store it on the item record and drop the resolver entirely. The cheapest query is the one you do not make.

Pipeline resolvers for multi-step logic

When a mutation needs to validate, then write, then publish an event, use a pipeline resolver: several functions sharing a ctx.stash.

  pipelineFunctions:
    checkOwnership:
      dataSource: itemsTable
      code: resolvers/fn.checkOwnership.js
    writeItem:
      dataSource: itemsTable
      code: resolvers/fn.writeItem.js
    publishEvent:
      dataSource: eventBus
      code: resolvers/fn.publishEvent.js

  resolvers:
    Mutation.archiveItem:
      kind: PIPELINE
      functions: [checkOwnership, writeItem, publishEvent]
      code: resolvers/Mutation.archiveItem.js

The eventBus data source can be AMAZON_EVENTBRIDGE, which means the mutation emits a domain event without a Lambda in the path — the downstream consumers are ordinary event-driven handlers, exactly as in our EventBridge and idempotency post.

Conditional writes belong in the resolver, not in application code:

// resolvers/fn.writeItem.js — abbreviated
export function request(ctx) {
  return {
    operation: 'UpdateItem',
    key: util.dynamodb.toMapValues({ pk: `ITEM#${ctx.args.id}`, sk: 'META' }),
    update: {
      expression: 'SET #s = :archived, updatedAt = :now',
      expressionNames: { '#s': 'status' },
      expressionValues: util.dynamodb.toMapValues({ ':archived': 'ARCHIVED', ':now': util.time.nowISO8601() }),
    },
    condition: { expression: 'attribute_exists(pk) AND ownerId = :me',
                 expressionValues: util.dynamodb.toMapValues({ ':me': ctx.identity.sub }) },
  };
}

ctx.identity.sub comes from the verified Cognito token. Never trust an owner id passed in as an argument.

Subscriptions without surprises

AppSync subscriptions are managed WebSockets, and they behave differently from what most teams expect:

  • A subscription only fires from a mutation executed through AppSync. A DynamoDB stream handler that writes directly to the table publishes nothing. If your writes happen outside GraphQL, add a "no-op" mutation with a NONE local data source and have the stream handler call it.
  • The payload is whatever the mutation's selection set returned, filtered by the subscriber's own selection set. If the mutation did not return a field, subscribers cannot get it.
  • Authorization is evaluated at connect time, not per message. A token that expires mid-stream keeps receiving data until the client reconnects — set token lifetimes accordingly.
  • Enhanced subscription filtering (util.transform.toSubscriptionFilter) lets you filter server-side on fields other than the subscription arguments. Use it; client-side filtering means you are paying for and leaking messages the client throws away.

Cost controls that actually matter

  • Caching. Per-resolver caching with a TTL turns hot reference data into near-zero-latency reads. Cache Query.itemsByStatus for 60 seconds and watch DynamoDB read units collapse. Key on $context.arguments plus, where relevant, identity.
  • Query depth and complexity limits. AppSync supports queryDepthLimit and resolverCountLimit. Set them. Without a depth limit, one nested client query can fan out into thousands of resolver executions — this is a denial-of-wallet vector, not just a performance issue.
  • Log at ERROR in production. ALL level logging on a busy AppSync API produces an astonishing volume of CloudWatch data. Use ALL in dev, ERROR in prod, and rely on X-Ray for traces (see our observability guide).
appSync:
  caching:
    behavior: PER_RESOLVER_CACHING
    ttl: 60
    type: SMALL
  queryDepthLimit: 6
  resolverCountLimit: 200

Testing and CI

APPSYNC_JS resolvers are testable without deploying. The EvaluateCode API runs your resolver code server-side against a context you supply:

aws appsync evaluate-code \
  --runtime name=APPSYNC_JS,runtimeVersion=1.0.0 \
  --code file://resolvers/Query.item.js \
  --function request \
  --context '{"arguments":{"id":"123"}}'

Wrap that in a Jest test per resolver and you have fast, real feedback on the request mapping — the part that breaks most often. For end-to-end coverage, deploy an ephemeral stage per pull request and run queries against it with a test user's token, as described in our testing guide. Schema changes deserve one more gate: run a breaking-change check (graphql-inspector diff) between the deployed schema and the branch schema, and fail the build on removed fields or narrowed types. GraphQL clients ship on their own schedule; a removed field is an outage for whoever is still on the old bundle.

Migrating an existing REST API

You rarely get to start clean. The pattern that works:

  1. Stand up AppSync alongside the existing API Gateway — same table, same Cognito pool, no changes to REST.
  2. Model one screen's data needs in the schema and point one new client view at it.
  3. For fields backed by business logic you do not want to reimplement, use an AWS_LAMBDA data source that calls the existing handler, or an HTTP data source that calls the existing REST endpoint. Both are legitimate intermediate steps.
  4. Move screens over one at a time. Retire REST endpoints only when access logs show zero traffic for a full client release cycle.

Do not attempt a big-bang cutover. GraphQL changes how the front end fetches data, and you want that change reviewable one screen at a time.

The short version

Use AppSync when your API is mostly data access over DynamoDB and you want managed subscriptions and no cold starts. Use Lambda-hosted GraphQL when your API is mostly business logic. Whichever you pick: deny by default, batch every list-to-detail field, cap query depth, and gate schema changes on a breaking-change check.

Designing a GraphQL layer over existing serverless services — or rescuing one where the bill or the p95 has gone the wrong way — is work we do regularly. Get in touch if you want a second opinion on the schema before it ships, or read about our Serverless API Creation and Serverless Architecture Design engagements.