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
Default Limits in Popular GraphQL Implementations
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.
| Platform | Default Limit | Configurable | Method | Notes |
|---|---|---|---|---|
| Apollo Server | None | Yes | Schema-level | Manual implementation required |
| GitHub GraphQL API | 100 nodes | No | Point system | 5,000 points/hour budget |
| Hasura | 20 rows | Yes | Permission-based | Per-role configuration |
| AWS AppSync | 1,000 items | Yes | Resolver-level | Per-field configuration |
| Shopify GraphQL | 250 items | No | Cost analysis | Throttling 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
firstinstead oflimitif 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.
- Add
offsetandlimitarguments to your schema - Validate and clamp both values server-side
- Apply
skip/takein your database query - Include a stable
orderByto prevent page drift - Return
totalCount,hasNextPage, andhasPreviousPagefor 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
};
}
}
};
| Advantages | Disadvantages |
|---|---|
| Simple to understand and implement | Query time grows linearly with offset size |
| Works with any relational database | Inconsistent results when data changes between pages |
| Easy to calculate total page count | Memory usage increases with large offsets |
| Familiar to developers | Unsuitable 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 Type | Recommended Limit | Reasoning | Example |
|---|---|---|---|
| One-to-Many | 10–50 | Balance detail with performance | User → Posts |
| Many-to-Many | 20–100 | Higher cardinality acceptable | Post → Tags |
| Nested One-to-Many | 5–20 | Multiplicative effect | User → Posts → Comments |
| Deep Nesting (3+ levels) | 3–5 | Exponential growth risk | User → 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
| Scenario | With Stable Sorting | Without Stable Sorting |
|---|---|---|
| Page 1 Results | Records 1–10 consistently | Records 1–10 initially |
| Page 2 Results | Records 11–20 consistently | May repeat or skip records |
| New Record Added | Appears in correct sorted position | May shift all page boundaries |
| Record Updated | Maintains position if sort field unchanged | May 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 Level | Recommended Limit | Performance Impact | Use Case |
|---|---|---|---|
| Top Level | 50–100 | Low | Primary entity lists |
| Second Level | 10–25 | Medium | Related entity details |
| Third Level | 5–10 | High | Nested relationships |
| Fourth Level+ | 3–5 | Very High | Deep 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 }
}
}
}
| Platform | Limitation Type | Key Constraint | Workaround Strategy |
|---|---|---|---|
| GitHub GraphQL | Point system | 5,000 points/hour | Reduce node count per query, batch requests |
| Shopify GraphQL | Cost analysis | 1,000 cost units/query | Fewer fields, shallower nesting, caching |
| AWS AppSync | Resolver limits | 10 MB response size | Split large queries, use pagination |
| Hasura | Permission-based | Role-specific row limits | Optimize 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
rateLimitquery 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 }
}
}
}
}
| Provider | Rate Limit Type | Calculation Method | Reset Period |
|---|---|---|---|
| GitHub | Point-based | Node count + complexity | Hourly rolling window |
| Shopify | Cost-based | Per-field cost analysis | Per-second leaky bucket |
| Apollo Studio | Operation-based | Request count | Monthly quota |
| Hasura Cloud | Request-based | Requests per minute | Sliding 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
- Enforce server-side maximum limits that no client can override
- Use cursor-based pagination for datasets over ~10,000 rows
- Always include a unique field as the final sort criterion
- Return pagination metadata (
hasNextPage,endCursor) in every list response - Document all limits explicitly in your schema with SDL descriptions
- Use DataLoader or a batching library to prevent N+1 queries
- Monitor query performance per field; tighten limits based on P95 data
- 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 Method | Offset 0–100 | Offset 1K–10K | Offset 100K+ | Memory Usage |
|---|---|---|---|---|
| Offset-based | ~50ms | ~200ms | ~2,000ms | Grows with offset |
| Cursor-based | ~50ms | ~50ms | ~50ms | Constant |
| Keyset pagination | ~45ms | ~45ms | ~45ms | Constant |
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] || []);
});
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| GraphQL Subscriptions | Real-time feeds | Live updates, no polling | Complex infra, connection management |
| @defer Directive | Expensive sub-fields | Faster perceived response | Limited client support |
| DataLoader (no pagination) | Small bounded sets | Eliminates N+1, simple | Not suitable for large datasets |
More GraphQL Performance Guides
- GraphQL Sorting — sort result sets correctly before you paginate or limit them.
- GraphQL OrderBy — implement deterministic orderBy arguments in your schema and resolvers.
- GraphQL Where Clause — combine filters with pagination for efficient seek queries.
- GraphQL Nested Query — structure nested queries without triggering exponential data growth.
- GraphQL Filter Multiple Values — filter lists by multiple criteria before applying limits.
- GraphQL Count — return accurate counts alongside paginated results.
- GraphQL Distinct — deduplicate results before limiting to avoid misleading counts.
- GraphQL Monitoring — track query performance and catch limit violations in production.
- GraphQL Timeout — set query timeouts as a last-resort safety net alongside result limits.
- GraphQL Rate Limit — add request-level rate limiting on top of result limits for full protection.
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.




