AppSync when you want a GraphQL endpoint AWS runs for you, and doubly so if subscriptions are part of the product; Apollo Server when the schema, the middleware and the data sources have to be yours, and you accept operating the process that serves them. Everything below follows from that single split: a managed endpoint billed per operation, or a library you host on Lambda, Fargate or a container and pay for by the compute hour.
What actually separates AppSync from Apollo Server?
They are not the same kind of object, and most comparison tables get that wrong on the first row. AWS AppSync is a managed GraphQL endpoint: you publish a schema, attach resolvers, and AWS runs the parser, the execution engine, the connection layer and the scaling. Apollo Server is a library. You import it into a Node process, you decide where that process lives, and you own everything around it: deployment, scaling, patching, logs, on-call.
So the question is never which GraphQL implementation is better. It is whether GraphQL should be a piece of infrastructure someone else operates, or a piece of your application. Teams already running containers, who want resolvers to be ordinary TypeScript next to the rest of their domain code, lean Apollo. Teams who want a schema, an endpoint and nothing new to wake up for lean AppSync. We work through the same reasoning on the REST side in our AppSync vs API Gateway comparison.
How do AppSync resolvers differ from Apollo resolvers?
On AppSync a resolver is a small JavaScript function with a request handler and a response handler, running in a deliberately constrained runtime: no network calls of your own, no npm packages, no long-running work. It returns a description of the operation, and AppSync performs it against the data source: DynamoDB, Aurora through the Data API, OpenSearch, an HTTP endpoint, or a Lambda function when the constrained runtime is not enough. VTL templates are the older form of the same idea and still fill a lot of existing stacks.
# Schema, identical whichever side you land on
type Order {
id: ID!
status: String!
}
type Mutation {
updateStatus(id: ID!, status: String!): Order
}
type Subscription {
onOrderUpdated(id: ID!): Order
@aws_subscribe(mutations: ["updateStatus"])
}
// AppSync JavaScript resolver: DynamoDB directly, no Lambda in the path
import { util } from '@aws-appsync/utils';
export function request(ctx) {
return {
operation: 'UpdateItem',
key: util.dynamodb.toMapValues({ id: ctx.args.id }),
update: {
expression: 'SET #s = :s',
expressionNames: { '#s': 'status' },
expressionValues: util.dynamodb.toMapValues({ ':s': ctx.args.status }),
},
};
}
export function response(ctx) {
return ctx.result;
}
The trade is real. A direct-to-DynamoDB resolver has no cold start and no function to maintain, which is hard to beat for plain reads and writes. The moment a field needs a third-party SDK, a cache client or shared domain code, you attach a Lambda resolver and you are back to writing and running functions. On Apollo the resolver is just a TypeScript function in your repository, free to import anything, with a DataLoader in context and the same testing story as the rest of the codebase.
// Apollo Server behind a Lambda handler: the resolver is ordinary TypeScript
import { ApolloServer } from '@apollo/server';
import { startServerAndCreateLambdaHandler, handlers } from '@as-integrations/aws-lambda';
const server = new ApolloServer({
typeDefs,
resolvers: {
Mutation: {
updateStatus: (_parent, args, ctx) => ctx.orders.setStatus(args.id, args.status),
},
},
});
export const handler = startServerAndCreateLambdaHandler(
server,
handlers.createAPIGatewayProxyEventV2RequestHandler(),
{
context: async ({ event }) => ({
orders: makeOrderService(),
user: await authenticateFromHeaders(event.headers),
}),
},
);
Authorization shows the same shape of trade. On AppSync, Cognito user pools, IAM, OIDC and API keys are declarative, applied per field where you want them, with a Lambda authorizer for whatever the built-ins miss. If your auth model already lives in Cognito or IAM, that is work you simply do not do. If it lives in a bespoke session service with rules per tenant, you will write that Lambda authorizer anyway, and an Apollo context function would have been more direct.
Why are subscriptions the sharpest dividing line?
Because the WebSocket layer is the part nobody wants to own. AppSync ships subscriptions over WebSockets inside the service: declare a subscription field, tie it to a mutation, and AWS handles connections, fan-out, filtering, reconnection and scale.
With Apollo Server, subscriptions are yours to run. That means a process holding connections open, which rules Lambda out for the socket itself and pushes you to Fargate, ECS or another container platform. It means a PubSub backend so a message published on one instance reaches subscribers attached to another, plus health checks, connection draining, and an answer to what happens when you deploy and every socket drops at once. When real-time sits at the centre of the product, this one row settles the comparison more often than any other.
Unsure whether your GraphQL layer should be managed or your own? Describe your system: a one-page diagnosis within 48 hours.
Get my diagnosis →Federation or Merged APIs: which fits several teams?
When one graph serves several teams, both sides have an answer, and the answers are not equivalent. Apollo Federation composes independently deployed subgraphs into one supergraph, with entity references that let a type declared in one subgraph be extended by another. It is the mature option: a router in front, schema checks, and a composition step in CI that fails the build when a change would break a consumer.
AppSync Merged APIs combine several source APIs into one endpoint, each team owning its own AppSync API, with conflict resolution at merge time. That covers namespacing and ownership well, but a merge is not federated entity resolution: a single type cannot be resolved across source APIs the way a federated entity can. Pick Merged APIs when teams own separate slices of the graph. Pick Federation when they own different fields of the same types.
AppSync vs Apollo on Lambda vs Apollo on Fargate
Naming the axes collapses most of the debate. The table below sets the three realistic deployments side by side, with the scenario each one wins.
| Criterion | AppSync | Apollo Server on Lambda | Apollo Server on Fargate |
|---|---|---|---|
| Who runs it | AWS: parser, executor, scaling, sockets | You own the code, AWS owns the runtime | You own the code, the task, the scaling policy |
| Subscriptions | Built in, WebSockets managed for you | Not viable: Lambda holds no long-lived socket | Yours to build, with a PubSub backend and draining |
| Federation | Merged APIs: a merge, not entity resolution | Full Apollo Federation as a subgraph | Full Apollo Federation, router included |
| Authorization | Declarative Cognito, IAM, OIDC, API keys, per field | Your own code in the context function | Your own code, plus whatever fronts the task |
| Resolver freedom | Constrained JS runtime, or a Lambda per field | Any package, any data source | Any package, any data source, warm connections |
| Cost shape | Per operation, plus real-time and optional cache | Per invocation and memory-time | Per vCPU-hour and GB-hour, idle included |
| When it wins | Real-time products, AWS-native auth, small teams | Spiky query traffic with custom resolver logic | Sustained traffic, subscriptions, federated graphs |
How portable is the schema, and what does leaving cost?
The schema itself is portable. GraphQL SDL is a standard, and a schema written for AppSync parses anywhere. What does not move is everything wrapped around it. JavaScript resolvers use the AppSync utilities and its request contract, VTL templates are AppSync only, and direct data source integrations, subscription semantics tied to mutations, caching configuration and declarative auth all have to be rebuilt as real code on the way out.
The honest framing is cost of exit, not lock-in as a slogan. Leaving AppSync means rewriting resolvers as functions, standing up a server and building a subscription layer: a project, not a migration script. Going the other way, Apollo to AppSync, means squeezing arbitrary resolver code into a constrained runtime or wrapping each field in Lambda, which often deletes the reason you wanted AppSync. Decide with that number in view, the way we frame the rest of the AWS service decisions we publish.
What do the two cost shapes look like?
Shapes, not figures. AppSync bills per query and mutation operation, with real-time charged separately per subscription update delivered and per connection-minute, and the optional server-side cache adding an hourly instance charge. Apollo Server has no per-operation price at all: you pay for whatever runs it, Lambda invocations and memory-time or Fargate vCPU-hours and GB-hours, plus the load balancer and the PubSub backend once subscriptions are in play.
That makes the crossover predictable. Spiky traffic with long idle stretches suits the per-operation shape, which charges nothing at rest. Sustained high-volume traffic suits the compute shape, where a task you keep busy serves a very large number of operations for a flat hourly rate. Model your real traffic histogram against AppSync pricing and the compute price of your chosen runtime before trusting either intuition, and count the operational hours Apollo adds even though no invoice lists them. If what fronts the service turns out to be the deciding factor, our API Gateway, ALB and Function URLs comparison covers that layer.
Decision checklist
- Subscriptions in the product and no appetite for running WebSocket servers: AppSync.
- Auth already in Cognito, IAM or an OIDC provider: AppSync, declared per field.
- Resolvers needing arbitrary packages, shared domain code or awkward data sources: Apollo Server.
- Several teams on different fields of the same types: Apollo Federation. Separate slices of the graph: AppSync Merged APIs.
- Spiky traffic with long idle stretches: per-operation billing. Sustained volume: a container you keep busy.
- Either way: schema checks in CI, persisted queries or a depth limit, and an alarm on resolver errors.