Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Build a GraphQL API with AWS AppSync

By Kokil Thapa | Last reviewed: August 2026

If you need to ship a managed GraphQL backend without maintaining EC2 instances or Apollo servers, you should build a GraphQL API with AWS AppSync. AppSync handles caching, real-time subscriptions, and multi-source data fetching natively, making it ideal for mobile apps and SPAs where latency matters. For developers accustomed to traditional REST architectures in frameworks like Laravel, the shift requires understanding resolver mapping templates rather than controller logic. This guide covers the production-critical configuration steps often missing from basic tutorials.

Transitioning from synchronous PHP/MySQL patterns to event-driven cloud APIs changes how you structure data access. While I spend most of my time shipping Laravel API best practices for legal-tech and eCommerce clients, AppSync solves specific scaling problems that monolithic frameworks struggle with—particularly when serving global mobile users or requiring offline-first synchronization. The trade-off is operational complexity in resolver debugging and vendor-specific tooling.

How do you design an AppSync schema for DynamoDB?

The most common failure point when engineers first build a GraphQL API with AWS AppSync is treating DynamoDB like a relational database. You cannot perform arbitrary joins or complex WHERE clauses efficiently. Your schema must reflect your access patterns, not your entity relationships. In practice, this means denormalizing data at write time so reads are single-item fetches or simple queries against Global Secondary Indexes (GSIs).

GraphQL QuerygetOrders(userId)@aws_cognito_user_poolsAppSync ResolverVTL / JS TemplateMaps args → KeyConditionDynamoDB TablePK: USER#userIdSK: ORDER#timestampGSI: OrdersByStatusPK: STATUS#activeSK: CREATED#dateAlternative Access Pattern
GraphQL queries map directly to DynamoDB partition keys and GSIs — never rely on post-fetch filtering

Define access patterns before writing SDL

List every query your frontend needs. For a legal case management portal, you might need "cases by client," "documents by case," and "appointments by attorney." Each becomes either a primary key lookup or a dedicated GSI. If a query doesn't map cleanly to one, redesign the table or accept that you'll need a Lambda resolver instead of direct DynamoDB access.

Use composite keys for hierarchical data

DynamoDB excels when you model parent-child relationships in a single partition. Use a partition key like CASE#caseId and sort keys prefixed with entity type: DOC#docId, TASK#taskId. This lets you fetch a case and all its related items in a single Query operation, which is far cheaper than multiple GetItem calls or application-side joins.

<!-- Example VTL request template for hierarchical query -->
{
  "version": "2018-05-29",
  "operation": "Query",
  "query": {
    "expression": "pk = :pk AND begins_with(sk, :prefix)",
    "expressionValues": {
      ":pk": $util.dynamodb.toDynamoDBJson("CASE#$ctx.args.caseId"),
      ":prefix": $util.dynamodb.toDynamoDBJson("DOC#")
    }
  }
}

Avoid nested resolvers for list fields

A common mistake is defining a Case.documents field with its own resolver that fires once per case in a list response. With 50 cases, that's 51 DynamoDB calls. Instead, use batch operations (BatchGetItem) or denormalize document summaries into the case item itself. Reserve nested resolvers only for detail views where the user explicitly requests expanded data.

What is the difference between VTL and JavaScript resolvers in 2026?

When you build a GraphQL API with AWS AppSync today, you have two resolver runtime options. Velocity Template Language (VTL) has been the default since launch, but AWS now recommends JavaScript resolvers for new projects. Both execute in the AppSync service layer—not in Lambda—and both compile to optimized evaluation plans. The choice affects developer experience, debugging capability, and long-term maintenance.

CriteriaVTL ResolversJavaScript Resolvers
SyntaxTemplate directives ($util, #if)Standard ES2022+ JavaScript
Type SafetyNone (string-based templates)TypeScript support via CDK/SAM
DebuggingCloudWatch logs onlyLocal testing + better error traces
EcosystemLimited community examplesNPM packages, shared utilities
Pipeline SupportNative (multi-step)Native (async/await syntax)
Learning CurveSteep (custom DSL)Moderate (standard JS)
Best ForLegacy systems, simple mappingsNew projects, complex logic

In my experience working on production serverless backends, JavaScript resolvers reduce cognitive load significantly. You can write familiar array methods, conditionals, and error handling instead of wrestling with VTL's whitespace-sensitive templating syntax. However, VTL remains relevant for existing deployments and ultra-simple pass-through mappings where JavaScript's overhead isn't justified.

Migrating from VTL to JavaScript incrementally

You don't need to rewrite everything at once. AppSync allows mixing runtimes per field. Start with new features in JavaScript, then migrate high-churn VTL resolvers during refactors. Test each migration against captured production requests using the AppSync console's test harness before deploying.

How do you implement secure authorization in AppSync?

Security configuration is where most AppSync projects fail audit. Unlike traditional REST APIs protected by middleware, AppSync supports four authorization modes simultaneously: API Key, IAM, Amazon Cognito User Pools, and OpenID Connect. Choosing correctly depends on your consumer profile. For client-facing applications serving authenticated users, Cognito User Pools should be your default. For backend-to-backend communication or CI/CD pipelines, use IAM signatures. Never use API Keys beyond prototyping—they lack identity context and expire after seven days unless rotated manually.

Mobile / Web ClientCognito JWT TokenAppSync EndpointValidate SignatureExtract ClaimsResolver Context$ctx.identity.username$ctx.identity.groupsField-Level Auth@aws_auth(cognito_groups:["ADMIN"])Optional Override
Cognito claims propagate through resolver context for row-level and field-level security enforcement

Implement row-level security in resolvers

Never trust the client to filter data. Even if your frontend only shows "my cases," a malicious actor can modify the query. In every resolver, validate ownership against $ctx.identity.username or group membership. For DynamoDB, bake the user ID into the partition key so unauthorized queries return empty results rather than throwing errors—which leak information about record existence.

// JavaScript resolver enforcing ownership
export function request(ctx) {
  const userId = ctx.identity.username;
  if (ctx.args.owner !== userId) {
    util.unauthorized();
  }
  return {
    operation: 'GetItem',
    key: {
      pk: util.dynamodb.toString(`USER#${userId}`),
      sk: util.dynamodb.toString(`CASE#${ctx.args.caseId}`)
    }
  };
}

Handle multi-auth scenarios gracefully

Many production systems serve both end-users (Cognito) and internal services (IAM). Define a default authorization mode at the API level, then override per-field using directives like @aws_iam or @aws_cognito_user_pools. Document these overrides clearly—mixing auth modes without explicit annotations leads to silent failures where legitimate requests get rejected because they hit the wrong default provider.

How do you enable real-time subscriptions in AppSync?

Real-time capabilities are a primary reason teams choose to build a GraphQL API with AWS AppSync over self-managed alternatives. Subscriptions use MQTT over WebSocket under the hood, managed entirely by AWS. You declare subscription operations in your schema tied to mutation names, and AppSync routes published events to connected clients automatically. No socket servers, no Redis pub/sub configuration, no connection state management.

Tie subscriptions to mutations explicitly

Subscriptions don't listen to tables—they listen to mutation invocations. When you define onCreateCase, AppSync publishes the mutation's return value to subscribers immediately after the mutation succeeds. If you bypass AppSync mutations (e.g., writing directly to DynamoDB via Lambda), no subscription fires. This coupling is intentional: it guarantees consistency between what was written and what was broadcast.

type Subscription {
  onCreateCase(owner: String!): Case
    @aws_subscribe(mutations: ["createCase"])
  
  onUpdateDocument(caseId: ID!, docType: String): Document
    @aws_subscribe(mutations: ["updateDocument"])
}

Filter subscriptions server-side

Client-side filtering wastes bandwidth and exposes sensitive metadata. Use subscription arguments to narrow the stream before it leaves AWS. A client subscribing to onUpdateDocument(caseId: "123") receives only updates for that case. Combine this with resolver-level authorization to ensure users can't subscribe to resources they shouldn't access—even if they guess valid IDs.

Manage connection lifecycle in production

WebSocket connections drop due to network changes, app backgrounding, or timeouts. Implement exponential backoff reconnection in your client SDK. On the server side, monitor Connect and Disconnect metrics in CloudWatch to detect abnormal churn rates. High disconnect frequency often indicates overly aggressive timeout settings or unhandled authentication token refresh cycles. For Nepal-based users on variable mobile networks, increase heartbeat intervals to prevent unnecessary reconnect storms.

Mutation CallercreateCase()AppSync EngineExecute + PublishDynamoDBPersist RecordSubscription FilterMatch owner/groupClient A✓ Receives EventClient B✗ Filtered OutClient C✓ Receives Event
Mutations trigger filtered subscription delivery — unmatched clients receive nothing, preserving bandwidth and security

When should you avoid AppSync for GraphQL?

Despite its strengths, AppSync isn't universal. Understanding its limits prevents costly rewrites later. If your workload involves heavy server-side computation, complex transactions across multiple tables, or deep integration with non-AWS ecosystems, evaluate alternatives carefully. For teams already running serverless Laravel on AWS Lambda, adding AppSync introduces a second paradigm that may not justify the operational split.

  • Complex relational queries: If you need JOINs, aggregations, or window functions, put Aurora Serverless behind a Lambda resolver—or stick with PostgreSQL and a traditional GraphQL server like Hasura or PostGraphile.
  • Vendor portability requirements: AppSync's resolver templates and subscription model are AWS-specific. Migrating away requires rewriting every resolver. If multi-cloud is a hard requirement, use an open-source framework deployed on ECS/Kubernetes.
  • High-frequency polling patterns: Subscriptions excel at push, but if your clients poll every few seconds regardless of changes, REST with HTTP caching or tRPC may be simpler and cheaper.
  • Team unfamiliarity with VTL/JS resolvers: The learning curve is real. Budget 2–4 weeks for developers to become productive. If timelines are tight and GraphQL isn't mandatory, REST with well-documented OpenAPI specs ships faster.

For Nepal-based startups evaluating costs, remember that AppSync charges per query/mutation plus data transfer. At low volumes, it's economical (~NPR 500–1,000/month for light usage). But at high query rates without aggressive caching, bills escalate faster than provisioned EC2. Always model expected costs using the AWS Pricing Calculator before committing.

Build a GraphQL API with AWS AppSync for Production

Shipping a reliable AppSync API requires discipline beyond the getting-started tutorial. Design your DynamoDB schema around access patterns, enforce authorization in every resolver, prefer JavaScript resolvers for new work, and treat subscriptions as mutation-coupled streams—not generic event buses. Monitor CloudWatch metrics for latency spikes and subscription churn. Start small with a single domain boundary, validate the pattern, then expand. If you're evaluating whether AppSync fits your next project or need help migrating an existing GraphQL workload, reach out to discuss your architecture.

Frequently Asked Questions

AWS AppSync is a managed GraphQL service that handles real-time subscriptions, offline sync, and multi-database resolvers without custom backend code. Unlike API Gateway which requires Lambda handlers for every operation, AppSync uses schema-driven resolvers to connect directly to DynamoDB, RDS, or HTTP endpoints. I have found this reduces boilerplate significantly for data-heavy applications where clients need flexible queries rather than fixed REST endpoints.

Pricing includes query/mutation/subscription operations plus data transfer. At current rates, one million operations costs roughly USD 4.00 (NPR 530). A small SaaS with 50k monthly active users typically runs USD 15–30/month (NPR 2,000–4,000) including DataStore sync. Costs scale linearly with operations, not idle time, making it predictable for growing Nepal-based startups compared to provisioned EC2 instances running 24/7.

Yes, via the Aurora Serverless v2 RDS proxy resolver. You define SQL statements in VTL or JavaScript resolvers mapped to your schema fields. This avoids Lambda cold starts for relational queries. In my experience building legal-tech portals, this pattern works well when you need GraphQL flexibility but must keep existing normalized MySQL schemas. Connection pooling is handled automatically by the RDS proxy layer.

Yes, by configuring HTTP resolvers that point to your Laravel API routes. AppSync acts as a gateway, transforming GraphQL requests into REST calls your Laravel controllers already handle. This lets mobile apps consume GraphQL while preserving your existing PHP business logic and validation. I have used this approach to add real-time features to legacy Laravel systems without rewriting the entire backend in Node.js or Python.

AppSync supports four auth modes simultaneously: API Key, IAM, Cognito User Pools, and OIDC. For client-facing apps, Cognito is standard; for server-to-server, use IAM or API keys. You can apply per-field authorization directives like @aws_cognito_user_pools to restrict sensitive resolvers. On projects requiring Nepal-specific compliance, I often combine Cognito for users with IAM for internal admin services accessing the same schema securely.

The N+1 problem is the most frequent issue. Fetching a list of orders then resolving each order's customer individually causes massive latency. Use BatchGetItem for DynamoDB or JOINs in RDS resolvers to fetch related data in single operations. Also avoid complex VTL transformations; push filtering to the database layer. Profiling resolver latency via CloudWatch Logs is essential before assuming AppSync itself is slow.

AppSync manages WebSocket connections, message fan-out, and reconnection logic automatically. You publish events from mutations using @aws_subscribe directives; no socket server code required. Clients receive updates only for subscribed fields. This eliminates the operational burden of managing Socket.io or Pusher infrastructure. For booking systems I have built, this reduced real-time implementation time from weeks to hours while maintaining reliability under load.

It depends on query patterns. AppSync excels at product catalogs, inventory checks, and cart operations with predictable access patterns. Complex checkout flows with multi-step transactions may still need traditional REST or Step Functions. For WooCommerce-integrated stores, I typically use AppSync for frontend browsing and search, while keeping payment processing on secure server-side PHP endpoints to maintain PCI compliance and transactional integrity.

Deploy AppSync alongside your existing API. Create GraphQL types that map to current REST endpoints via HTTP resolvers. Mobile and web teams adopt GraphQL incrementally while legacy clients continue using REST. Monitor usage via CloudWatch metrics. Once migration completes, deprecate old endpoints. This parallel-run strategy has worked reliably on client projects where downtime during API transitions was unacceptable for business continuity.

Unit test VTL/JS resolver templates locally using the AppSync simulator or amplify mock api. Integration tests should hit a deployed dev environment with seeded test data since local simulators cannot replicate all AWS service behaviors. Use Jest or Pytest to validate request/response mapping. Never rely solely on console testing; automated regression catches schema drift. I include resolver tests in CI pipelines alongside application code to prevent silent breakage during deploys.

DataStore persists GraphQL data locally using IndexedDB (web) or SQLite (mobile). Changes queue offline and sync automatically when connectivity returns, with conflict resolution via version numbers or custom strategies. Define sync expressions to limit downloaded data to relevant subsets. This is critical for field-service apps in Nepal with intermittent connectivity. Configure TTL and storage limits carefully to avoid bloating device storage on low-end Android devices common in emerging markets.

Yes, using HTTP resolvers with API key or OAuth headers stored in Secrets Manager. Map external JSON responses to your GraphQL schema. Handle retries and timeouts within resolver configuration or pipeline functions. For payment gateways like eSewa, I recommend keeping webhook verification server-side in Lambda or PHP, exposing only safe status queries through AppSync to prevent exposing secret keys or signature validation logic to clients.

VTL is verbose and hard to debug but has zero cold start overhead. JavaScript resolvers (Node 18+) allow familiar syntax, npm packages, and better error handling at slight latency cost. AWS now recommends JS resolvers for new projects due to maintainability. Use VTL only for simple mappings or extreme throughput needs. On recent builds, I default to JS resolvers unless profiling shows measurable performance degradation under sustained load exceeding thousands of RPS.

Enable CloudWatch Logs at FIELD level during development, INFO for production. Track latency, error rates, and cache hits via built-in metrics. Set alarms on 4xx/5xx spikes and p99 latency. Use X-Ray tracing to correlate resolver calls with downstream DynamoDB or Lambda invocations. Client-side errors require capturing GraphQL response metadata. Without proper observability, debugging production issues becomes guesswork; I treat monitoring setup as mandatory infrastructure, not optional.

Choose AppSync when you need fine-grained resolver control, multiple data sources, or custom auth beyond Amplify defaults. Use Amplify Gen 2 for rapid prototyping with opinionated conventions. Access DynamoDB directly only for simple server-side backends without client sync needs. AppSync adds complexity justified by real-time requirements or heterogeneous data integration. Evaluate based on actual architectural needs, not hype; many projects succeed with simpler stacks that avoid managed GraphQL overhead entirely.

Share this article

Quick Contact Options
Choose how you want to connect me: