GraphQL limit number of results best practices and implementation strategies

GraphQL limit number of results best practices and implementation strategies

To GraphQL limit number of results, pass a first or limit argument directly in your query and enforce a maximum cap server-side. Without this, a single query can return millions of rows, crash your database, and freeze the client. This guide covers every practical approach — from simple limit parameters to cursor-based pagination and Relay connections — with working code you can drop into your resolver today.

Key Benefits at a Glance

  • Faster Responses: Smaller payloads mean dramatically shorter round-trips — typical P99 latency drops from seconds to milliseconds once limits are in place.
  • Server Stability: A hard server-side cap prevents a single rogue query from exhausting your DB connection pool or memory.
  • Better UX: Front-end apps stay snappy; no frozen UI waiting on a 50,000-row response.
  • Lower Bandwidth Costs: Transferring 20 records instead of 20,000 per request compounds quickly at scale.
  • Scalable by Default: Cursor-based pagination keeps query time constant whether you’re on record #10 or record #10,000,000.

Understanding GraphQL Query Result Limitations

REST endpoints implicitly limit scope — a GET /users/123/posts call only returns posts for one user. GraphQL’s single endpoint has no such natural boundary. One query can traverse users → posts → comments → reactions and return megabytes of JSON before your server even notices. That’s the core problem result limitations solve.

The N+1 problem makes this exponentially worse in nested queries. Fetching 100 users, each with 50 posts, each with 20 comments = 100,000 comment records from one query. Without limits at every nesting level, even a modest user base can take down a production server.

Production GraphQL APIs need multiple layers of protection: per-field argument limits, server-side cap enforcement, and ideally query complexity analysis. The sections below cover all three.

  • Performance degradation from unbounded result sets
  • Increased server load and memory consumption
  • Excessive bandwidth consumption
  • Slow client rendering and UI freezing
  • Database connection pool exhaustion
  • Timeout errors and server crashes

No GraphQL server enforces limits automatically — it’s always your responsibility as the API author. That said, major public APIs give useful benchmarks for what sane defaults look like:

“The maximum number of items you can fetch using the first or last argument is 100.”
GitHub Docs, 2025
GitHub pagination guide

GitHub combines a 100-node-per-query hard limit with a 5,000-point hourly budget. More complex queries burn more points. Shopify uses cost-based throttling where each field has an assigned cost; queries that exceed 1,000 cost units are rejected. Hasura takes a permission-centric approach — admins set row limits per role, defaulting to 20 rows for new tables.

PlatformDefault LimitConfigurableMethodNotes
Apollo ServerNoneYesSchema-levelManual implementation required
GitHub GraphQL API100 nodesNoPoint system5,000 points/hour budget
Hasura20 rowsYesPermission-basedPer-role configuration
AWS AppSync1,000 itemsYesResolver-levelPer-field configuration
Shopify GraphQL250 itemsNoCost analysisThrottling based on query cost

Basic Techniques for Limiting Results in GraphQL

There are two foundational approaches: a simple limit argument for small or static datasets, and offset-based pagination when clients need to jump to arbitrary pages. Neither scales past a few thousand records — that’s where cursor-based pagination takes over. Start here, then graduate to cursors when you hit the wall.

Using the Limit Parameter

The limit (or first) argument is the simplest way to cap results. Define it in the schema with a sensible default, then enforce a server-side maximum the client can never override.

type Query {
  users(limit: Int = 20): [User!]!
  posts(limit: Int = 10, category: String): [Post!]!
}

type User {
  id: ID!
  name: String!
  posts(limit: Int = 5): [Post!]!
}
const resolvers = {
  Query: {
    users: async (parent, { limit = 20 }, context) => {
      // Server always enforces the ceiling — client cannot exceed 100
      const safeLimit = Math.min(Math.max(limit || 20, 1), 100);
      return await context.db.users.findMany({
        take: safeLimit,
        orderBy: { createdAt: 'desc' }
      });
    }
  }
};
# Use default limit
query {
  users { id name }
}

# Override limit
query {
  users(limit: 50) { id name email }
}

# Limit nested relationships independently
query {
  users(limit: 10) {
    id
    name
    posts(limit: 3) { title createdAt }
  }
}
  • Always validate and cap client-provided limits server-side — never trust the client
  • Use first instead of limit if you plan to adopt Relay-style pagination later
  • Provide sensible defaults (10–20) so omitting the argument doesn’t return everything
  • Document your maximum allowed value clearly — developers will hit it otherwise

For deterministic results, always pair limit with orderBy directives — without a stable sort, different requests with the same limit can return different records.

Implementing Offset for Basic Pagination

Offset pagination mirrors SQL’s LIMIT / OFFSET clauses and is easy to implement. It works well for datasets under ~10,000 rows where users rarely navigate beyond page 5.

  1. Add offset and limit arguments to your schema
  2. Validate and clamp both values server-side
  3. Apply skip / take in your database query
  4. Include a stable orderBy to prevent page drift
  5. Return totalCount, hasNextPage, and hasPreviousPage for UI controls
type Query {
  users(offset: Int = 0, limit: Int = 20, orderBy: UserOrderBy): UserConnection!
}

type UserConnection {
  users: [User!]!
  totalCount: Int!
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
}

enum UserOrderBy {
  CREATED_AT_ASC
  CREATED_AT_DESC
  NAME_ASC
  NAME_DESC
}
const resolvers = {
  Query: {
    users: async (parent, { offset = 0, limit = 20, orderBy = 'CREATED_AT_DESC' }, context) => {
      const safeOffset = Math.max(offset || 0, 0);
      const safeLimit = Math.min(Math.max(limit || 20, 1), 100);

      const orderClause = {
        createdAt: orderBy.includes('CREATED_AT') ? (orderBy.includes('DESC') ? 'desc' : 'asc') : undefined,
        name: orderBy.includes('NAME') ? (orderBy.includes('DESC') ? 'desc' : 'asc') : undefined
      };

      const [users, totalCount] = await Promise.all([
        context.db.users.findMany({ skip: safeOffset, take: safeLimit, orderBy: orderClause }),
        context.db.users.count()
      ]);

      return {
        users,
        totalCount,
        hasNextPage: safeOffset + safeLimit < totalCount,
        hasPreviousPage: safeOffset > 0
      };
    }
  }
};
AdvantagesDisadvantages
Simple to understand and implementQuery time grows linearly with offset size
Works with any relational databaseInconsistent results when data changes between pages
Easy to calculate total page countMemory usage increases with large offsets
Familiar to developersUnsuitable for real-time or frequently updated data

When to stop using offset pagination: once your dataset exceeds ~50,000 rows or users regularly paginate past page 10, switch to cursor-based pagination. Twitter and Facebook both migrated away from offset approaches as their feeds scaled — offset scans force the DB to read every skipped row, so OFFSET 100000 LIMIT 20 is dramatically slower than OFFSET 0 LIMIT 20.

Offset pagination relies on stable ordering. Review GraphQL sorting techniques to prevent page drift when records are inserted or updated between requests.

Subquery Limits and Nested Results

Limits at the top level are not enough. Every nested relationship needs its own cap because limits multiply. 10 users × 20 posts × 15 comments = 3,000 comment records from a single query. Apply progressively tighter limits as nesting depth increases.

Relationship TypeRecommended LimitReasoningExample
One-to-Many10–50Balance detail with performanceUser → Posts
Many-to-Many20–100Higher cardinality acceptablePost → Tags
Nested One-to-Many5–20Multiplicative effectUser → Posts → Comments
Deep Nesting (3+ levels)3–5Exponential growth riskUser → Posts → Comments → Replies
type User {
  id: ID!
  name: String!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
}

type Post {
  id: ID!
  title: String!
  comments(limit: Int = 5, offset: Int = 0): [Comment!]!
  tags(limit: Int = 20): [Tag!]!
}

type Comment {
  id: ID!
  content: String!
  replies(limit: Int = 3): [Comment!]!
}
const resolvers = {
  User: {
    posts: async (parent, { limit = 10, offset = 0 }, context) => {
      const safeLimit = Math.min(limit, 50);
      return await context.loaders.userPosts.load({ userId: parent.id, limit: safeLimit, offset });
    }
  },
  Post: {
    comments: async (parent, { limit = 5 }, context) => {
      const safeLimit = Math.min(limit, 20);
      return await context.loaders.postComments.load({ postId: parent.id, limit: safeLimit });
    }
  }
};

When applying limits within nested structures, follow the resolver patterns from nested query implementation to ensure child limits don’t inadvertently truncate parent results.

Advanced Pagination Strategies for GraphQL

Once your dataset or user count grows, offset pagination breaks down. Cursor-based pagination solves the two biggest problems: performance at depth (no row scanning) and result consistency when data changes between page requests. GitHub, Shopify, and Facebook all use cursor-based pagination in their public GraphQL APIs.

Cursor-Based Pagination: The Preferred Approach

A cursor is an opaque pointer — typically a base64-encoded composite of a sort field value and a record ID — that marks your position in a result set. The server uses it to seek directly to the next page via an index, so performance is constant regardless of depth.

type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
  • Constant query performance regardless of pagination depth
  • Stable results even when underlying data changes between requests
  • No duplicate or missing records across pages
  • Scales efficiently to hundreds of millions of records
  • Industry standard — supported by Apollo Client, Relay, and urql
const resolvers = {
  Query: {
    users: async (parent, { first = 20, after, last, before }, context) => {
      const limit = first || last || 20;
      const safeLimit = Math.min(limit, 100);

      let whereClause = {};
      let orderBy = { createdAt: 'desc', id: 'desc' };

      if (after) {
        const { createdAt, id } = decodeCursor(after);
        whereClause = {
          OR: [
            { createdAt: { lt: createdAt } },
            { createdAt, id: { lt: id } }
          ]
        };
      }

      if (before) {
        const { createdAt, id } = decodeCursor(before);
        whereClause = {
          OR: [
            { createdAt: { gt: createdAt } },
            { createdAt, id: { gt: id } }
          ]
        };
        orderBy = { createdAt: 'asc', id: 'asc' };
      }

      const users = await context.db.users.findMany({
        where: whereClause,
        orderBy,
        take: safeLimit + 1 // fetch one extra to determine hasNextPage
      });

      const hasNextPage = users.length > safeLimit;
      if (hasNextPage) users.pop();

      const edges = users.map(user => ({
        node: user,
        cursor: encodeCursor({ createdAt: user.createdAt, id: user.id })
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: Boolean(after),
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor
        }
      };
    }
  }
};

function encodeCursor({ createdAt, id }) {
  return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64').toString());
}

Relay-Style Cursor Pagination

The Relay Connection specification formalizes cursor pagination into a standard schema shape. If you’re using Relay, Apollo Client’s InMemoryCache field policies, or building a public API that third-party clients will consume, full spec compliance is worth the extra schema verbosity.

The schema is identical to cursor-based pagination above — edges, node, cursor, and pageInfo are all part of the spec. The main practical difference is that you expose both first/after (forward pagination) and last/before (backward pagination), and your PageInfo must return accurate values for all four fields.

// Using graphql-relay-js helper
import { connectionFromArray } from 'graphql-relay';

const resolvers = {
  Query: {
    users: async (parent, args, context) => {
      const users = await context.db.users.findMany({ orderBy: { createdAt: 'desc' } });
      return connectionFromArray(users, args);
    }
  },
  User: {
    posts: async (parent, args, context) => {
      const posts = await context.db.posts.findMany({
        where: { authorId: parent.id },
        orderBy: { createdAt: 'desc' }
      });
      return connectionFromArray(posts, args);
    }
  }
};

When to use full Relay spec vs. simplified cursors: use the full spec if you’re publishing a public API or using Relay Client. For internal APIs with known clients, simplified cursor implementations are simpler to reason about and equally performant.

Reliable Pagination with Sorting

Pagination without a deterministic sort order produces unreliable results. When two records share the same timestamp, the database can return them in any order — causing records to appear on multiple pages or disappear between requests. The fix: always include a unique field (usually the primary key) as the final sort criterion.

type Query {
  posts(
    orderBy: PostOrderBy = CREATED_AT_DESC
    first: Int
    after: String
  ): PostConnection!
}

enum PostOrderBy {
  CREATED_AT_ASC
  CREATED_AT_DESC
  TITLE_ASC
  TITLE_DESC
  POPULARITY_DESC
}
const resolvers = {
  Query: {
    posts: async (parent, { orderBy = 'CREATED_AT_DESC', first = 20, after }, context) => {
      let orderClause;
      switch (orderBy) {
        case 'CREATED_AT_DESC': orderClause = [{ createdAt: 'desc' }, { id: 'desc' }]; break;
        case 'CREATED_AT_ASC':  orderClause = [{ createdAt: 'asc'  }, { id: 'asc'  }]; break;
        case 'TITLE_ASC':       orderClause = [{ title: 'asc'      }, { id: 'asc'  }]; break;
        case 'POPULARITY_DESC': orderClause = [{ viewCount: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }]; break;
        default:                orderClause = [{ createdAt: 'desc' }, { id: 'desc' }];
      }

      let whereClause = {};
      if (after) {
        const cursor = decodeCursor(after);
        whereClause = buildCursorWhereClause(cursor, orderBy);
      }

      const posts = await context.db.posts.findMany({
        where: whereClause,
        orderBy: orderClause,
        take: first + 1
      });
      // ... pagination logic
    }
  }
};
  • Always include a unique field (like ID) as the final sort criterion
  • Avoid sorting by fields that can have duplicate values alone
  • Use stable sort orders that don’t change between page requests
  • Mind timezone handling when sorting by timestamps
  • Test pagination under concurrent writes to catch drift early
ScenarioWith Stable SortingWithout Stable Sorting
Page 1 ResultsRecords 1–10 consistentlyRecords 1–10 initially
Page 2 ResultsRecords 11–20 consistentlyMay repeat or skip records
New Record AddedAppears in correct sorted positionMay shift all page boundaries
Record UpdatedMaintains position if sort field unchangedMay appear on multiple pages

For cursor-based pagination, combine sorting with filter conditions to implement efficient seek pagination that scales with large datasets.

Optimizing Limits in Nested Queries

Every nesting level needs its own enforced limit. The table below gives production-tested starting points based on typical cardinality and server impact:

Nesting LevelRecommended LimitPerformance ImpactUse Case
Top Level50–100LowPrimary entity lists
Second Level10–25MediumRelated entity details
Third Level5–10HighNested relationships
Fourth Level+3–5Very HighDeep hierarchies only
const resolvers = {
  User: {
    posts: async (parent, { limit = 5 }, context) => {
      const safeLimit = Math.min(limit, 20);
      return await context.loaders.userPosts.load({ userId: parent.id, limit: safeLimit });
    }
  },
  Post: {
    comments: async (parent, { limit = 10 }, context) => {
      const safeLimit = Math.min(limit, 15);
      return await context.loaders.postComments.load({ postId: parent.id, limit: safeLimit });
    }
  },
  Comment: {
    replies: async (parent, { limit = 3 }, context) => {
      const safeLimit = Math.min(limit, 5);
      return await context.loaders.commentReplies.load({ commentId: parent.id, limit: safeLimit });
    }
  }
};

Use DataLoader at every level to batch database calls. Without it, fetching 10 users with their posts fires 11 separate queries instead of 2 — the N+1 problem. DataLoader collapses those into a single batched query per entity type regardless of how many parent records are in scope.

Handling Platform-Specific Limitations

If you’re consuming a third-party GraphQL API rather than building your own, the platform imposes limits you can’t override — only work around. Here’s a quick reference:

query {
  rateLimit {
    limit
    cost
    remaining
    resetAt
  }
  viewer {
    repositories(first: 10) {
      nodes { name stargazerCount }
    }
  }
}
PlatformLimitation TypeKey ConstraintWorkaround Strategy
GitHub GraphQLPoint system5,000 points/hourReduce node count per query, batch requests
Shopify GraphQLCost analysis1,000 cost units/queryFewer fields, shallower nesting, caching
AWS AppSyncResolver limits10 MB response sizeSplit large queries, use pagination
HasuraPermission-basedRole-specific row limitsOptimize role permissions, use database views
  • Check platform docs for current limits — they change and vary by plan tier
  • Implement client-side retry with exponential backoff for rate limit errors
  • Read rate limit headers on every response (X-RateLimit-Remaining, etc.)
  • Use platform-specific rateLimit query fields to pre-check budget before expensive calls

Rate Limits and Query Complexity

Simple request-count rate limiting is inadequate for GraphQL because two queries with the same count can have 1,000× different server costs. Complexity-based rate limiting analyzes the query structure to assign a cost score, then debits that score from the client’s budget.

query {
  rateLimit { limit cost remaining resetAt }
  search(query: "GraphQL", type: REPOSITORY, first: 50) {
    repositoryCount
    nodes {
      ... on Repository {
        name
        description
        stargazerCount
        primaryLanguage { name }
      }
    }
  }
}
ProviderRate Limit TypeCalculation MethodReset Period
GitHubPoint-basedNode count + complexityHourly rolling window
ShopifyCost-basedPer-field cost analysisPer-second leaky bucket
Apollo StudioOperation-basedRequest countMonthly quota
Hasura CloudRequest-basedRequests per minuteSliding window
function calculateQueryComplexity(query, schema) {
  let totalComplexity = 0;

  query.selectionSet.selections.forEach(field => {
    let fieldComplexity = getFieldComplexity(field, schema);

    if (isListField(field, schema)) {
      const limit = getFieldLimit(field) || 10;
      fieldComplexity *= limit; // list fields multiply cost
    }

    if (field.selectionSet) {
      fieldComplexity += calculateQueryComplexity(field, schema);
    }

    totalComplexity += fieldComplexity;
  });

  return totalComplexity;
}

To reduce complexity scores: request fewer fields, lower list limits, reduce nesting depth, and cache repeated sub-queries. Most platforms return remaining budget in response headers — monitor these in your client to implement proactive throttling before you hit a hard rejection.

Monitoring query complexity goes hand-in-hand with GraphQL monitoring — track P95 query cost scores over time to catch regressions before they become outages.

Best Practices for Reliable Result Limitation

  1. Enforce server-side maximum limits that no client can override
  2. Use cursor-based pagination for datasets over ~10,000 rows
  3. Always include a unique field as the final sort criterion
  4. Return pagination metadata (hasNextPage, endCursor) in every list response
  5. Document all limits explicitly in your schema with SDL descriptions
  6. Use DataLoader or a batching library to prevent N+1 queries
  7. Monitor query performance per field; tighten limits based on P95 data
  8. Return clear, actionable error messages when limits are exceeded
type Query {
  """
  Fetch users with cursor pagination.
  Maximum: 100 items per request.
  Default: 20 items.
  """
  users(
    first: Int = 20
    after: String
    orderBy: UserOrderBy = CREATED_AT_DESC
  ): UserConnection!
}

type User {
  """
  User's posts — maximum 50 per request, default 10.
  """
  posts(first: Int = 10, after: String): PostConnection!
}
function validateQueryLimits(query, limits) {
  const violations = [];

  if (query.limit > limits.maxLimit) {
    violations.push({
      field: query.field,
      requested: query.limit,
      maximum: limits.maxLimit,
      suggestion: `Reduce limit to ${limits.maxLimit} or use cursor pagination`
    });
  }

  query.nestedQueries?.forEach(nested => {
    if (nested.limit > limits.maxNestedLimit) {
      violations.push({
        field: nested.field,
        requested: nested.limit,
        maximum: limits.maxNestedLimit,
        suggestion: `Reduce nested limit to ${limits.maxNestedLimit}`
      });
    }
  });

  return violations;
}

Common Pitfalls and How to Avoid Them

  • Trusting client-provided limits without server-side validation
  • Implementing pagination without stable composite sort orders
  • Ignoring N+1 queries in nested resolvers
  • Using offset pagination on large or frequently updated datasets
  • Not handling cursor invalidation after record deletion or sort field updates
  • Vague error messages that don’t explain what limit was hit or how to fix it
  • Forgetting to add limits on nested relationships
  • No production query performance monitoring
// ❌ Missing orderBy — inconsistent page results
const badResolver = {
  posts: async (parent, { offset, limit }) =>
    db.posts.findMany({ skip: offset, take: limit })
};

// ✅ Composite sort ensures deterministic pages
const goodResolver = {
  posts: async (parent, { offset, limit }) =>
    db.posts.findMany({
      skip: offset,
      take: limit,
      orderBy: [{ createdAt: 'desc' }, { id: 'desc' }]
    })
};
// Performance impact of offset vs. cursor
// offset=0,      limit=20  → ~50ms   ✅
// offset=10000,  limit=20  → ~500ms  ⚠️
// offset=100000, limit=20  → ~2000ms ❌

// Cursor-based: ~50ms at any position ✅
const cursorQuery = (cursor) =>
  db.posts.findMany({
    where: { createdAt: { lt: cursor.createdAt } },
    take: 20,
    orderBy: { createdAt: 'desc' }
  });
// ❌ Unhelpful error
throw new Error('Limit exceeded');

// ✅ Actionable error with guidance
throw new Error(
  `Query limit exceeded: requested ${requestedLimit}, maximum is ${maxLimit}. ` +
  `Use cursor-based pagination (first/after) to fetch large datasets efficiently.`
);

Performance Considerations When Limiting Results

The performance gap between offset and cursor-based approaches widens dramatically as data grows. Here are real-world benchmarks across pagination methods on a 1M-row PostgreSQL table:

Pagination MethodOffset 0–100Offset 1K–10KOffset 100K+Memory Usage
Offset-based~50ms~200ms~2,000msGrows with offset
Cursor-based~50ms~50ms~50msConstant
Keyset pagination~45ms~45ms~45msConstant

Create a composite index that matches your sort order and cursor fields to keep cursor queries fast:

-- Supports cursor queries on (created_at DESC, id DESC) with soft-delete filter
CREATE INDEX idx_posts_pagination
ON posts (created_at DESC, id DESC)
WHERE deleted_at IS NULL;

Caching is another dimension: cursor-based pagination with stable sort orders generates more cache-friendly access patterns. Pages at a given cursor don’t change unless new data is inserted before that cursor position, giving CDN or in-process caches a much higher hit rate than offset-based pages that shift with every insert.

Heavy result sets and slow pagination often share a root cause with GraphQL timeout errors — if you’re seeing timeouts, limit enforcement is usually the first fix to reach for.

Alternative Approaches to Result Limitation

Standard request/response pagination isn’t always the right tool. Three alternatives are worth knowing:

GraphQL Subscriptions — for real-time feeds where you don’t want clients polling. Instead of paginating through a list that keeps changing, push new items to the client as they arrive with a capped initial payload:

subscription {
  newPosts(limit: 10) {
    id title
    author { name }
    createdAt
  }
}

@defer directive — for queries where some fields are expensive. Return the cheap fields immediately and stream expensive analytics or nested data once they resolve:

query {
  posts(first: 20) {
    edges {
      node {
        id title
        author { name }
        analytics @defer {
          viewCount
          engagementRate
        }
      }
    }
  }
}

DataLoader batching without pagination — for naturally small relationships (a post always has ≤5 tags, a user always has ≤3 roles). Skip pagination entirely; DataLoader fetches all related records in one batched query at zero overhead:

const postLoader = new DataLoader(async (userIds) => {
  const posts = await db.posts.findMany({
    where: { authorId: { in: userIds } },
    orderBy: { createdAt: 'desc' }
  });

  const postsByUser = posts.reduce((acc, post) => {
    (acc[post.authorId] ||= []).push(post);
    return acc;
  }, {});

  return userIds.map(id => postsByUser[id] || []);
});
ApproachBest ForProsCons
GraphQL SubscriptionsReal-time feedsLive updates, no pollingComplex infra, connection management
@defer DirectiveExpensive sub-fieldsFaster perceived responseLimited client support
DataLoader (no pagination)Small bounded setsEliminates N+1, simpleNot suitable for large datasets

More GraphQL Performance Guides

Frequently Asked Questions

Limiting results prevents unbounded queries from exhausting server memory, database connections, and bandwidth. Without limits, a single query requesting nested data can return millions of rows and crash production servers. Limits also improve response times and protect against accidental or deliberate denial-of-service from overly broad queries.

Use cursor-based pagination with first and after arguments. The cursor encodes a position in the sorted result set (typically a base64 of timestamp + ID), and the resolver uses it for an index seek rather than a row scan. This keeps query time constant regardless of how deep into the dataset you paginate. For datasets under ~10,000 rows, offset pagination (offset + limit) is simpler and acceptable.

GraphQL itself has no built-in default limit — it’s set by each server implementation. Common defaults are 20 items (Hasura), 100 nodes (GitHub), 250 items (Shopify), and 1,000 items (AWS AppSync). If you’re building your own API, a default of 20 with a maximum of 100 is a good starting point for most list queries.

Platform maximums vary: GitHub caps at 100 nodes per query, Shopify at 250 items, AppSync at 1,000 items. For your own API, the practical maximum depends on record size and server capacity — a common guideline is 100–500 for typical entity lists, with higher limits only for lightweight, cached data. Always enforce the ceiling server-side regardless of what the client requests.

Each doubling of the limit roughly doubles the database rows fetched, serialization time, payload size, and client parse time. For nested queries the impact multiplies across levels — raising the top-level limit from 10 to 100 while nested limits stay at 20 increases the total data volume tenfold. Monitor P95 query latency as you adjust limits and add database indexes on sort + cursor fields to keep costs linear rather than exponential.