MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

GraphQL Mutations and Subscriptions: Beyond Queries

Master GraphQL mutations and subscriptions: real-time data, optimistic updates, and error handling.

GraphQLMutationsSubscriptionsReal-Time

By MinhVo

Introduction

While GraphQL queries handle reading data, mutations and subscriptions handle the other two critical pillars of data management: writing data and receiving real-time updates. Mutations allow clients to modify server-side data with typed inputs and responses, while subscriptions push data changes to clients in real-time using WebSocket connections.

Mastering these operations is essential for building responsive, real-time applications. In this guide, we'll implement robust mutations with optimistic updates, build real-time subscriptions with WebSocket transport, and explore patterns for handling errors and edge cases in production applications.

Real-time data architecture

Understanding Mutations and Subscriptions: Core Concepts

GraphQL Mutations Explained

Mutations are GraphQL's mechanism for modifying server-side data. Unlike queries, mutations are executed serially by the GraphQL specification, ensuring that writes happen in order. A mutation defines input types for the data being sent and returns the modified object:

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): DeleteUserPayload!
}
 
input CreateUserInput {
  name: String!
  email: String!
  role: Role = USER
}
 
type CreateUserPayload {
  user: User!
  errors: [Error!]
}

The mutation payload pattern is a best practice that allows returning both the result and any validation errors in a single response. This is more flexible than relying on GraphQL errors alone, which are meant for system-level failures.

GraphQL Subscriptions Explained

Subscriptions are long-lived operations that push data to clients when specific events occur. Unlike queries and mutations that use HTTP, subscriptions typically use WebSocket connections for bidirectional communication:

type Subscription {
  messageSent(channelId: ID!): Message!
  userStatusChanged(userId: ID!): UserStatus!
  orderUpdated(orderId: ID!): Order!
}

When a client creates a subscription, the server maintains the WebSocket connection and pushes the subscription payload whenever the subscribed event fires. This enables real-time features like chat, live notifications, and collaborative editing.

Error Handling Patterns

GraphQL provides two mechanisms for reporting errors: top-level errors array for system failures (authentication, server errors) and typed error fields in mutation payloads for business logic failures (validation, conflicts):

// System error (in errors array)
{
  "errors": [{
    "message": "Not authenticated",
    "extensions": { "code": "UNAUTHENTICATED" }
  }]
}
 
// Business error (in payload)
{
  "data": {
    "createUser": {
      "user": null,
      "errors": [
        { "field": "email", "message": "Already in use" }
      ]
    }
  }
}

Architecture and Design Patterns

The Payload Pattern

The payload pattern structures mutation responses to include both success data and error information. This gives clients precise control over error handling:

interface MutationResponse {
  success: Boolean!
  errors: [Error!]
}
 
type CreateUserPayload implements MutationResponse {
  success: Boolean!
  errors: [Error!]
  user: User
}
 
type Error {
  field: String
  message: String!
  code: ErrorCode!
}

The Event Bus Pattern

Subscriptions require an event bus to decouple mutation logic from subscription delivery. When a mutation modifies data, it publishes an event to the bus. Subscriptions listen for these events and push updates to connected clients:

import { PubSub } from "graphql-subscriptions";
 
const pubsub = new PubSub();
 
const resolvers = {
  Mutation: {
    sendMessage: async (_, { input }, { user, db }) => {
      const message = await db.message.create({
        data: { ...input, authorId: user.id },
      });
 
      // Publish event for subscriptions
      pubsub.publish("MESSAGE_SENT", {
        messageSent: message,
        channelId: input.channelId,
      });
 
      return { success: true, message };
    },
  },
  Subscription: {
    messageSent: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator("MESSAGE_SENT"),
        (payload, variables) => payload.channelId === variables.channelId
      ),
    },
  },
};

Optimistic Update Pattern

Optimistic updates immediately reflect changes in the UI before the server confirms them. This makes the application feel instant while the actual request completes in the background:

const [updateUser] = useMutation(UPDATE_USER, {
  optimisticResponse: {
    __typename: "Mutation",
    updateUser: {
      __typename: "User",
      id: userId,
      name: newName,
      // Include all fields from the selection set
    },
  },
  update(cache, { data: { updateUser } }) {
    // Update normalized cache
    cache.modify({
      id: cache.identify(updateUser),
      fields: {
        name: () => newName,
      },
    });
  },
  onError(error) {
    // Rollback on error - Apollo automatically reverts optimistic update
    showToast(`Update failed: ${error.message}`);
  },
});

Optimistic update flow

Step-by-Step Implementation

Defining Mutation Schema

Start by designing your mutation schema with proper input types and payload types:

type Mutation {
  # CRUD operations
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!
 
  # Batch operations
  bulkDeletePosts(ids: [ID!]!): BulkDeletePayload!
 
  # Actions with side effects
  publishPost(id: ID!): PublishPostPayload!
  archivePost(id: ID!): ArchivePostPayload!
}
 
input CreatePostInput {
  title: String!
  content: String!
  categoryId: ID!
  tags: [String!]
  scheduledAt: DateTime
}
 
input UpdatePostInput {
  title: String
  content: String
  categoryId: ID
  tags: [String!]
}
 
type CreatePostPayload {
  success: Boolean!
  errors: [Error!]
  post: Post
}
 
type DeletePostPayload {
  success: Boolean!
  errors: [Error!]
  deletedId: ID
}

Implementing Mutation Resolvers

Build resolvers that validate input, perform the operation, and publish events for subscriptions:

const mutationResolvers = {
  Mutation: {
    createPost: async (_, { input }, { user, db, pubsub }) => {
      // Validate input
      const validation = validateCreatePostInput(input);
      if (!validation.valid) {
        return { success: false, errors: validation.errors, post: null };
      }
 
      // Check authorization
      if (!user || !user.permissions.includes("CREATE_POST")) {
        return {
          success: false,
          errors: [{ message: "Not authorized", code: "FORBIDDEN" }],
          post: null,
        };
      }
 
      // Create the post
      const post = await db.post.create({
        data: {
          ...input,
          authorId: user.id,
          status: input.scheduledAt ? "SCHEDULED" : "DRAFT",
        },
        include: { author: true, category: true },
      });
 
      // Publish event for real-time subscriptions
      pubsub.publish("POST_CREATED", { postCreated: post });
 
      return { success: true, errors: [], post };
    },
 
    updatePost: async (_, { id, input }, { user, db, pubsub }) => {
      const post = await db.post.findUnique({ where: { id } });
      if (!post) {
        return {
          success: false,
          errors: [{ message: "Post not found", code: "NOT_FOUND" }],
          post: null,
        };
      }
 
      if (post.authorId !== user.id && !user.permissions.includes("ADMIN")) {
        return {
          success: false,
          errors: [{ message: "Not authorized", code: "FORBIDDEN" }],
          post: null,
        };
      }
 
      const updated = await db.post.update({
        where: { id },
        data: input,
        include: { author: true, category: true },
      });
 
      pubsub.publish("POST_UPDATED", { postUpdated: updated });
 
      return { success: true, errors: [], post: updated };
    },
  },
};

Setting Up WebSocket Subscriptions

Configure Apollo Server with WebSocket transport for subscriptions:

import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { makeExecutableSchema } from "@graphql-tools/schema";
import express from "express";
import { createServer } from "http";
 
const schema = makeExecutableSchema({ typeDefs, resolvers });
 
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
  path: "/graphql",
});
 
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      const token = ctx.connectionParams?.authToken;
      const user = token ? await verifyToken(token) : null;
      return { user, pubsub };
    },
  },
  wsServer
);
 
// HTTP server for queries and mutations
const apolloServer = new ApolloServer({
  schema,
  plugins: [
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});
 
const app = express();
const httpServer = createServer(app);
 
await apolloServer.start();
app.use("/graphql", express.json(), expressMiddleware(apolloServer));
 
httpServer.listen(4000);

Implementing Client-Side Subscriptions

Use Apollo Client's useSubscription hook to receive real-time updates:

import { useSubscription, useMutation, gql } from "@apollo/client";
 
const MESSAGE_SENT = gql`
  subscription OnMessageSent($channelId: ID!) {
    messageSent(channelId: $channelId) {
      id
      content
      author {
        id
        name
        avatar
      }
      createdAt
    }
  }
`;
 
const SEND_MESSAGE = gql`
  mutation SendMessage($input: SendMessageInput!) {
    sendMessage(input: $input) {
      success
      errors { message }
      message {
        id
        content
        author { id name }
        createdAt
      }
    }
  }
`;
 
function ChatChannel({ channelId }: { channelId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
 
  const { data, loading } = useSubscription(MESSAGE_SENT, {
    variables: { channelId },
    onData: ({ data }) => {
      setMessages((prev) => [...prev, data.messageSent]);
    },
  });
 
  const [sendMessage] = useMutation(SEND_MESSAGE, {
    optimisticResponse: {
      sendMessage: {
        __typename: "SendMessagePayload",
        success: true,
        errors: [],
        message: {
          __typename: "Message",
          id: "temp-id",
          content: "Sending...",
          author: { __typename: "User", id: "current-user", name: "You" },
          createdAt: new Date().toISOString(),
        },
      },
    },
  });
 
  return (
    <div>
      <MessageList messages={messages} />
      <MessageInput onSend={(content) => sendMessage({ variables: { input: { channelId, content } } })} />
    </div>
  );
}

WebSocket subscription flow

Real-World Use Cases

Collaborative Document Editing

A document editing application uses subscriptions to broadcast cursor positions, text changes, and presence updates. Mutations handle document saves and version creation. The combination enables real-time collaboration where multiple users see each other's changes instantly.

Live Auction Platform

An auction platform uses subscriptions for bid updates and countdown timers. When a user places a bid via mutation, the subscription pushes the new bid to all watchers. Optimistic updates make the bidder's UI update instantly while the mutation confirms the bid.

IoT Dashboard

An IoT dashboard subscribes to sensor data streams. Each sensor publishes readings via mutations, and the subscription delivers them to the dashboard in real-time. Filtering at the subscription level ensures each dashboard widget only receives relevant data.

Best Practices for Production

  1. Use the payload pattern for mutations: Always return typed payloads with success status and error arrays rather than relying solely on GraphQL errors.

  2. Implement optimistic updates for mutations: Update the UI immediately and roll back on error. This makes your app feel instant.

  3. Use withFilter for subscription filtering: Filter events at the subscription level to avoid sending irrelevant data to clients.

  4. Implement connection keepalive: Send periodic keepalive messages on WebSocket connections to detect and handle disconnections gracefully.

  5. Use DataLoader in subscription resolvers: Subscription resolvers may trigger entity resolution, so ensure they use batching to avoid N+1 queries.

  6. Rate-limit mutations: Implement rate limiting on mutations to prevent abuse, especially for mutations that trigger expensive operations.

  7. Validate subscription authentication: Verify authentication tokens in the WebSocket connection context, not in each subscription resolver.

  8. Implement reconnection logic on the client: Handle WebSocket disconnections with automatic reconnection and missed event replay.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing optimistic response fieldsUI flickers on updateInclude all fields from selection set in optimistic response
Unfiltered subscriptionsClients receive irrelevant eventsUse withFilter to filter by subscription variables
Memory leaks from subscriptionsServer memory grows over timeImplement connection cleanup and subscription disposal
Race conditions in mutationsData inconsistencyUse database transactions and optimistic locking
WebSocket authentication gapsUnauthorized data accessVerify auth tokens in connection context

Performance Optimization

Optimize mutation and subscription performance by batching database writes, using database transactions for atomic operations, and implementing efficient event filtering:

// Batch mutations with transactions
const resolvers = {
  Mutation: {
    bulkUpdatePosts: async (_, { updates }, { db }) => {
      return db.$transaction(
        updates.map(({ id, input }) =>
          db.post.update({ where: { id }, data: input })
        )
      );
    },
  },
};

Comparison with Alternatives

FeatureGraphQL SubscriptionsWebSocket (raw)Server-Sent EventsPolling
Type safetyFullNoneNoneFull (with queries)
BidirectionalYesYesNoNo
Schema integrationYesNoNoYes
ComplexityModerateHighLowLow
ScalabilityGoodGoodGoodPoor
Browser supportUniversalUniversalUniversalUniversal

Advanced Patterns

Subscription Deduplication

When multiple clients subscribe to the same event with identical parameters, deduplicate them to reduce server load:

class SubscriptionManager {
  private subscriptions = new Map<string, Set<string>>();
 
  addSubscription(channelId: string, clientId: string) {
    if (!this.subscriptions.has(channelId)) {
      this.subscriptions.set(channelId, new Set());
    }
    this.subscriptions.get(channelId)!.add(clientId);
  }
 
  getSubscriberCount(channelId: string): number {
    return this.subscriptions.get(channelId)?.size || 0;
  }
}

Optimistic Locking

Prevent concurrent mutation conflicts using version-based optimistic locking:

const updatePost = async (_, { id, input, expectedVersion }, { db }) => {
  const result = await db.post.updateMany({
    where: { id, version: expectedVersion },
    data: { ...input, version: { increment: 1 } },
  });
 
  if (result.count === 0) {
    return {
      success: false,
      errors: [{ message: "Post was modified by another user", code: "CONFLICT" }],
      post: null,
    };
  }
 
  return { success: true, errors: [], post: await db.post.findUnique({ where: { id } }) };
};

Testing Strategies

Test mutations and subscriptions using mock PubSub and WebSocket test clients:

describe("Message mutations and subscriptions", () => {
  it("sends message and receives via subscription", async () => {
    const pubsub = new PubSub();
    const mockDb = createMockDb();
    const server = createTestServer({ pubsub, db: mockDb });
 
    // Subscribe
    const subscription = server.executeSubscription({
      query: `subscription { messageSent(channelId: "1") { id content } }`,
    });
 
    // Mutate
    await server.executeOperation({
      query: `mutation { sendMessage(input: { channelId: "1", content: "Hello" }) { success } }`,
    });
 
    // Verify subscription received the message
    const result = await subscription.next();
    expect(result.value.data.messageSent.content).toBe("Hello");
  });
});

Future Outlook

GraphQL subscriptions are evolving with the graphql-ws protocol replacing the deprecated subscriptions-transport-ws. Future developments include subscription batching, incremental delivery for large payloads, and better integration with edge computing platforms for low-latency real-time applications.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Mutations and subscriptions are essential for building interactive, real-time GraphQL applications. The key takeaways are:

  1. Use the payload pattern for typed mutation responses with explicit error handling
  2. Implement optimistic updates for responsive UIs that don't wait for server confirmation
  3. Use WebSocket transport with graphql-ws for modern, secure subscriptions
  4. Filter subscriptions at the server level to avoid sending irrelevant data to clients
  5. Handle authentication at the WebSocket connection level for subscription security

Start with well-typed mutations using the payload pattern, then add subscriptions for the features that truly need real-time updates. Combined with optimistic updates, these patterns create applications that feel instant and responsive.