Guide · AWS AppSync · Conflict Detection

AppSync Conflict Detection for MCP Servers — VERSION Strategy, Optimistic Concurrency, and Lambda Resolution

AppSync conflict detection prevents lost updates when two clients mutate the same DynamoDB item concurrently — a realistic scenario when multiple agent processes update shared MCP server configuration records. AppSync implements conflict detection only for DynamoDB data sources and uses the _version field as the concurrency control token. When conflict detection is enabled, every DynamoDB mutation generated by AppSync includes a conditional expression that requires the item's current _version to match the value the client sent — a version mismatch means another writer already updated the item, and AppSync invokes the configured conflict handler to decide what to do. Three handlers are available: OPTIMISTIC_CONCURRENCY (reject the mutation with a conflict error — client must re-read and retry), AUTOMERGE (AppSync merges non-conflicting fields automatically using DynamoDB Amplify DataStore semantics — only suitable for Amplify-generated schemas), and LAMBDA (your Lambda receives the conflicting versions and returns the resolved item — full control for MCP config records with custom merge logic). The version field is auto-incremented by AppSync on every successful write — the client does NOT control the version value, only passes it back as a pre-condition.

TL;DR

Enable conflict detection on the DynamoDB data source (CDK: ConflictDetection.VERSION). AppSync adds _version, _lastChangedAt, and _deleted fields to every item it writes. Mutation inputs must include the current _version value (read from a prior query). If the version doesn't match, AppSync calls the conflict handler: OPTIMISTIC_CONCURRENCY returns a ConflictUnhandled error; LAMBDA receives both versions and returns the merged item (or null to reject). AUTOMERGE is designed for Amplify DataStore and not appropriate for hand-written MCP schemas. Clear _deleted and _version from client-visible GraphQL types using @deprecated or a transform layer to avoid leaking internal fields.

How _version, _lastChangedAt, and _deleted work

When conflict detection is enabled, AppSync manages three metadata fields on every DynamoDB item. These are implementation details of AppSync's conflict resolution, not domain fields — they should be included in your DynamoDB schema but typically hidden from the GraphQL schema or marked as system fields.

# GraphQL schema with conflict detection fields exposed
# (needed if clients need to read _version for mutation inputs)
type McpServerConfig {
  serverId: ID!
  alertWebhookUrl: String
  alertThresholdMs: Int
  slackChannel: String
  # Conflict detection fields — clients must include _version in mutation inputs
  _version: Int!
  _lastChangedAt: AWSTimestamp!
  _deleted: Boolean
}

input UpdateServerConfigInput {
  serverId: ID!
  alertWebhookUrl: String
  alertThresholdMs: Int
  slackChannel: String
  # Client must pass the _version value it received from the last read
  # AppSync checks: DynamoDB item's _version == input._version (else conflict)
  _version: Int!
}

type Mutation {
  updateServerConfig(input: UpdateServerConfigInput!): McpServerConfig
    @aws_cognito_user_pools
}

# The _version value AppSync stores in DynamoDB after a successful write:
# new_version = old_version + 1
# _lastChangedAt = current epoch millis
# _deleted = false (or true for delete mutations)

When AppSync detects a conflict (the item's current _version in DynamoDB doesn't match the client's _version), it passes both the client's proposed item (newItem) and the current DynamoDB item (existingItem) to the conflict handler. The client's newItem contains the mutation input fields; the existingItem is the latest item from DynamoDB at the time of the conflict check.

OPTIMISTIC_CONCURRENCY: reject and let the client retry

OPTIMISTIC_CONCURRENCY is the simplest conflict handler: on version mismatch, AppSync rejects the mutation with a ConflictUnhandled error. The client must re-fetch the item to get the current _version, apply its intended changes on top of the latest state, and retry. This is appropriate for MCP server alert configuration where two simultaneous updates are uncommon and the cost of a retry is acceptable.

// Client-side retry pattern for OPTIMISTIC_CONCURRENCY
// Using Amplify GraphQL client (handles ConflictUnhandled automatically)

async function updateAlertConfig(serverId, changes) {
  let retries = 0;
  const maxRetries = 3;

  while (retries < maxRetries) {
    try {
      // Step 1: Read current version
      const current = await appsyncQuery(`
        query GetConfig($serverId: ID!) {
          getServerConfig(serverId: $serverId) {
            serverId alertWebhookUrl alertThresholdMs slackChannel _version
          }
        }
      `, { serverId });

      const currentVersion = current.data.getServerConfig._version;

      // Step 2: Apply changes and submit with current version
      const result = await appsyncMutation(`
        mutation UpdateConfig($input: UpdateServerConfigInput!) {
          updateServerConfig(input: $input) {
            serverId alertWebhookUrl alertThresholdMs _version
          }
        }
      `, {
        input: {
          serverId,
          ...changes,
          _version: currentVersion  // must match DynamoDB's current _version
        }
      });

      return result.data.updateServerConfig;

    } catch (err) {
      // AppSync returns ConflictUnhandled when _version mismatches
      if (err.errors?.[0]?.errorType === 'ConflictUnhandled') {
        retries++;
        await sleep(100 * retries); // brief backoff before re-read
        continue;
      }
      throw err; // non-conflict error — don't retry
    }
  }
  throw new Error(`Failed to update after ${maxRetries} retries — too many concurrent writers`);
}

LAMBDA conflict handler: custom merge logic for MCP config records

When MCP server configuration fields are owned by different subsystems (one process updates alert thresholds, another updates webhook URLs), a Lambda conflict handler can merge the changes field-by-field rather than rejecting one writer. The Lambda receives both versions and returns the merged item — or null to reject the mutation (equivalent to OPTIMISTIC_CONCURRENCY behavior).

// Lambda conflict handler for AppSync
// Invoked when DynamoDB write fails due to _version mismatch
export const handler = async (event) => {
  // event shape:
  // {
  //   typeName: "McpServerConfig",
  //   fieldName: "updateServerConfig",
  //   operation: "UPDATE",
  //   newItem: {    // what the client tried to write (merged with current values)
  //     serverId: "srv-123",
  //     alertWebhookUrl: "https://hooks.example.com/new",
  //     alertThresholdMs: 5000,
  //     slackChannel: "#mcp-alerts",   // from client
  //     _version: 7                    // client's version (now stale)
  //   },
  //   existingItem: {  // the actual current DynamoDB item
  //     serverId: "srv-123",
  //     alertWebhookUrl: "https://hooks.example.com/old",
  //     alertThresholdMs: 3000,
  //     slackChannel: "#mcp-alerts-v2",  // already updated by another writer
  //     _version: 8                      // current version in DynamoDB
  //   }
  // }

  const { newItem, existingItem } = event;

  // Field-level last-writer-wins merge:
  // For non-null fields in newItem, take the new value
  // For null fields in newItem, keep the existing value (this write didn't intend to change it)
  const merged = {
    ...existingItem,
    // Only overwrite fields the client explicitly set (non-null in newItem)
    ...(newItem.alertWebhookUrl != null && { alertWebhookUrl: newItem.alertWebhookUrl }),
    ...(newItem.alertThresholdMs != null && { alertThresholdMs: newItem.alertThresholdMs }),
    ...(newItem.slackChannel != null && { slackChannel: newItem.slackChannel }),
    // DO NOT set _version — AppSync manages this
  };

  // Return merged item to accept with merged values
  // AppSync will write this to DynamoDB and increment _version
  return merged;

  // Return null to reject (same as OPTIMISTIC_CONCURRENCY)
  // return null;
};

// CDK: Lambda conflict handler on DynamoDB data source
const conflictLambda = new lambda.Function(this, 'ConflictHandler', {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset('lambda/conflict-handler')
});

const configTableDs = new appsync.DynamoDbDataSource(this, 'ConfigTableDs', {
  api,
  table: serverConfigTable,
  serviceRole: configTableRole
});

// Enable conflict detection with Lambda handler on the data source
// (CDK: use L1 CfnDataSource for conflict handler configuration)
const cfnDs = configTableDs.node.defaultChild as appsync.CfnDataSource;
cfnDs.dynamoDbConfig = {
  tableName: serverConfigTable.tableName,
  awsRegion: this.region,
  conflictDetection: 'VERSION',
  conflictHandler: 'LAMBDA',
  lambdaConflictHandlerArn: conflictLambda.functionArn
};

_version TTL cleanup and deleted items

When AppSync soft-deletes an item (via a delete mutation with conflict detection enabled), it sets _deleted: true on the item rather than removing it from DynamoDB. This allows clients using Amplify DataStore to detect deletions via delta sync. For hand-written MCP schemas where you don't use DataStore, these _deleted items accumulate permanently unless you add a DynamoDB TTL attribute to expire them.

// DynamoDB TTL on _deleted items — CDK setup
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';

const serverConfigTable = new dynamodb.Table(this, 'ServerConfigTable', {
  partitionKey: { name: 'serverId', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  // TTL attribute for soft-deleted items
  timeToLiveAttribute: '_ttl'
});

// In the conflict handler or a DynamoDB Streams Lambda,
// set _ttl = current_epoch + 7 days when _deleted = true
// DynamoDB will expire and remove the item after that timestamp

// Alternatively, filter _deleted items in your GraphQL resolver:
// export function response(ctx) {
//   if (ctx.result._deleted) return null;  // treat as not found
//   return ctx.result;
// }

// For list queries, filter _deleted items in the DynamoDB scan/query filter:
// FilterExpression: "attribute_not_exists(#d) OR #d = :false"
// ExpressionAttributeNames: { "#d": "_deleted" }
// ExpressionAttributeValues: { ":false": { BOOL: false } }

Failure modes reference

FailureSymptomFix
Client omits _version from mutation inputAppSync writes without version check — conflict detection silently disabled for that callMake _version required in the mutation input type (_version: Int!); validate in the resolver before executing the DynamoDB operation
Lambda conflict handler returns undefinedAppSync treats undefined as rejection — mutation fails with internal errorReturn null explicitly to reject; return the merged item object to accept — never return undefined
AUTOMERGE on non-Amplify schemaAutomerge silently discards fields that don't match Amplify DataStore type conventionsUse OPTIMISTIC_CONCURRENCY or LAMBDA for hand-written schemas; AUTOMERGE is only reliable with @model Amplify-generated types
Conflict handler Lambda not granted IAM invoke permissionConflict resolution fails with access denied; all conflicting mutations return errorGrant lambda:InvokeFunction to the AppSync service role for the conflict handler Lambda ARN; check the DynamoDB data source's execution role
_deleted items returned in list queriesDeleted server configs appear in results; clients see "ghost" entriesAdd FilterExpression to exclude _deleted = true items in DynamoDB query resolvers; or filter in the resolver response handler
Version counter overflow (very long-lived items with many writes)Theoretical: _version exceeds Int max (2,147,483,647)Use Long / AWSTimestamp for _version if the item will receive >2B writes; in practice, reset _version by recreating the item for stable server configs