REST APIs send you everything whether you need it or not. Request a user profile and you get 40 fields when you needed 3. Need data from two endpoints? That's two round trips. GraphQL fixes both problems: you specify exactly what you want, and the server returns exactly that — in a single request.
Flutter has two main GraphQL clients: graphql_flutter (widget-based, quick to adopt) and
Ferry (code-generated,
type-safe). I've used both in production. This guide covers both — starting
with graphql_flutter for most sections, then showing the Ferry approach for teams that want
compile-time safety.
What You'll Learn
- Setting up
graphql_flutterwith auth headers - Writing queries, mutations, and subscriptions
- Cache management with normalized caching
- Cursor-based and offset-based pagination
- Type-safe GraphQL with Ferry and code generation
- Error handling and retry patterns
- Production deployment tips
1. Why GraphQL in Flutter
GraphQL isn't better than REST in every situation. Use it when it solves a real problem:
| Factor | REST | GraphQL |
|---|---|---|
| Data fetching | Fixed response shape | Client picks exact fields |
| Multiple resources | Multiple requests | Single request |
| Over-fetching | Common | Eliminated by design |
| Under-fetching | Need extra endpoints | Nest related data |
| Versioning | URL-based (v1, v2) | Schema evolution, no versions |
| Tooling complexity | Low | Higher (schema, codegen) |
| Caching | HTTP caching built-in | Client-side normalized cache |
The tipping point is data complexity. If your screens pull from 3+ endpoints or show deeply nested relationships (user → orders → items → reviews), GraphQL turns 5 REST calls into 1 GraphQL query.
2. Setting Up graphql_flutter
Add the graphql_flutter
package to your project. It wraps the core graphql client with Flutter-specific widgets like Query, Mutation,
and Subscription.
# pubspec.yaml
dependencies:
graphql_flutter: ^5.2.0
import 'package:graphql_flutter/graphql_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive for persistent caching (uses hive package internally)
// See: https://pub.dev/packages/hive
await initHiveForFlutter();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final httpLink = HttpLink('https://api.example.com/graphql');
final authLink = AuthLink(
getToken: () async {
final token = await SecureStorage.getToken();
return token != null ? 'Bearer $token' : null;
},
);
final link = authLink.concat(httpLink);
final client = ValueNotifier(
GraphQLClient(
link: link,
cache: GraphQLCache(store: HiveStore()),
),
);
return GraphQLProvider(
client: client,
child: const MaterialApp(home: HomeScreen()),
);
}
}
3. Running Queries
graphql_flutter provides a Query widget that rebuilds when data arrives:
const String getProductsQuery = r'''
query GetProducts($category: String, $limit: Int!) {
products(category: $category, limit: $limit) {
id
name
price
imageUrl
rating
reviewCount
}
}
''';
class ProductListScreen extends StatelessWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: Query(
options: QueryOptions(
document: gql(getProductsQuery),
variables: const {'category': 'electronics', 'limit': 20},
fetchPolicy: FetchPolicy.cacheAndNetwork,
),
builder: (result, {fetchMore, refetch}) {
if (result.isLoading && result.data == null) {
return const Center(child: CircularProgressIndicator());
}
if (result.hasException) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: ${result.exception.toString()}'),
ElevatedButton(
onPressed: refetch,
child: const Text('Retry'),
),
],
),
);
}
final products = result.data!['products'] as List;
return RefreshIndicator(
onRefresh: () async => refetch!(),
child: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product['name']),
subtitle: Text('\$${product['price']}'),
trailing: Text('${product['rating']} ★'),
);
},
),
);
},
),
);
}
}
Imperative Queries (Without Widgets)
For queries outside the widget tree (in repositories, services, BLoCs):
class ProductRepository {
final GraphQLClient _client;
ProductRepository(this._client);
Future<List<Product>> getProducts({String? category, int limit = 20}) async {
final result = await _client.query(
QueryOptions(
document: gql(getProductsQuery),
variables: {'category': category, 'limit': limit},
fetchPolicy: FetchPolicy.networkOnly,
),
);
if (result.hasException) {
throw GraphQLException(result.exception!);
}
return (result.data!['products'] as List)
.map((json) => Product.fromJson(json))
.toList();
}
}
4. Running Mutations
const String createOrderMutation = r'''
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
status
total
items {
productId
quantity
price
}
}
}
''';
class CheckoutScreen extends StatelessWidget {
const CheckoutScreen({super.key});
@override
Widget build(BuildContext context) {
return Mutation(
options: MutationOptions(
document: gql(createOrderMutation),
onCompleted: (data) {
if (data != null) {
final orderId = data['createOrder']['id'];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Order $orderId created!')),
);
Navigator.of(context).pushReplacementNamed('/orders');
}
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed: ${error.toString()}')),
);
},
// Update the cache after mutation
update: (cache, result) {
// Evict the products list so it refetches
cache.writeQuery(
Request(operation: Operation(document: gql(getProductsQuery))),
data: null,
);
},
),
builder: (runMutation, result) {
return ElevatedButton(
onPressed: result!.isLoading
? null
: () => runMutation({
'input': {
'items': [
{'productId': 'p1', 'quantity': 2},
{'productId': 'p3', 'quantity': 1},
],
'shippingAddress': '123 Main St',
},
}),
child: result.isLoading
? const CircularProgressIndicator()
: const Text('Place Order'),
);
},
);
}
}
5. Variables and Fragments
Fragments let you reuse field selections across queries:
const String productFields = r'''
fragment ProductFields on Product {
id
name
price
imageUrl
rating
reviewCount
category {
id
name
}
}
''';
const String getProductQuery = r'''
query GetProduct($id: ID!) {
product(id: $id) {
...ProductFields
description
specifications {
key
value
}
reviews(limit: 5) {
author
rating
comment
createdAt
}
}
}
''' + productFields;
const String getRelatedProducts = r'''
query GetRelated($categoryId: ID!, $excludeId: ID!, $limit: Int!) {
relatedProducts(categoryId: $categoryId, excludeId: $excludeId, limit: $limit) {
...ProductFields
}
}
''' + productFields;
6. Caching Strategies
graphql_flutter uses a normalized cache. Every object with an id (or
__typename + id) gets stored once. Update it in one place and it updates everywhere
automatically.
// Fetch policies control how the cache is used:
// 1. cacheFirst — use cache, only hit network if cache misses
QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.cacheFirst, // Default
);
// 2. networkOnly — always hit the network, update cache
QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.networkOnly,
);
// 3. cacheAndNetwork — show cache immediately, then update from network
// Best UX: instant display + fresh data
QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.cacheAndNetwork,
);
// 4. noCache — don't read or write cache
QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.noCache,
);
Manual Cache Updates
// Read from cache
final cachedData = client.cache.readQuery(
Request(
operation: Operation(document: gql(getProductsQuery)),
variables: const {'category': 'electronics', 'limit': 20},
),
);
// Write to cache (optimistic update)
client.cache.writeQuery(
Request(
operation: Operation(document: gql(getProductsQuery)),
variables: const {'category': 'electronics', 'limit': 20},
),
data: updatedData,
);
// Evict a specific entity
client.cache.evict('Product:p123');
// Clear the entire cache
client.cache.store.reset();
7. Real-Time Subscriptions
Subscriptions maintain a WebSocket connection for real-time data:
// Add WebSocket link to your client
final wsLink = WebSocketLink(
'wss://api.example.com/graphql',
config: SocketClientConfig(
autoReconnect: true,
inactivityTimeout: const Duration(seconds: 30),
initialPayload: () async {
final token = await SecureStorage.getToken();
return {'Authorization': 'Bearer $token'};
},
),
);
// Split link: subscriptions go through WebSocket, everything else through HTTP
final link = Link.split(
(request) => request.isSubscription,
wsLink,
authLink.concat(httpLink),
);
const String onNewMessage = r'''
subscription OnNewMessage($chatId: ID!) {
messageAdded(chatId: $chatId) {
id
text
sender {
id
name
avatarUrl
}
createdAt
}
}
''';
class ChatScreen extends StatelessWidget {
final String chatId;
const ChatScreen({super.key, required this.chatId});
@override
Widget build(BuildContext context) {
return Subscription(
options: SubscriptionOptions(
document: gql(onNewMessage),
variables: {'chatId': chatId},
),
builder: (result) {
if (result.isLoading) {
return const Text('Connecting...');
}
if (result.hasException) {
return Text('Error: ${result.exception}');
}
final message = result.data?['messageAdded'];
if (message == null) return const SizedBox.shrink();
return MessageBubble(
text: message['text'],
sender: message['sender']['name'],
time: DateTime.parse(message['createdAt']),
);
},
);
}
}
8. Pagination: Cursor and Offset
Cursor-Based Pagination
const String paginatedProducts = r'''
query PaginatedProducts($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
node {
id
name
price
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
''';
class PaginatedProductList extends StatelessWidget {
const PaginatedProductList({super.key});
@override
Widget build(BuildContext context) {
return Query(
options: QueryOptions(
document: gql(paginatedProducts),
variables: const {'first': 20},
),
builder: (result, {fetchMore, refetch}) {
if (result.isLoading && result.data == null) {
return const Center(child: CircularProgressIndicator());
}
final connection = result.data!['products'];
final edges = connection['edges'] as List;
final pageInfo = connection['pageInfo'];
return NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
if (scrollInfo.metrics.pixels >
scrollInfo.metrics.maxScrollExtent - 200 &&
pageInfo['hasNextPage'] == true &&
!result.isLoading) {
fetchMore!(
FetchMoreOptions(
variables: {'after': pageInfo['endCursor']},
updateQuery: (previous, fetchMoreResult) {
final newEdges =
fetchMoreResult!['products']['edges'] as List;
final merged = {
'products': {
'edges': [
...previous!['products']['edges'] as List,
...newEdges,
],
'pageInfo': fetchMoreResult['products']['pageInfo'],
},
};
return merged;
},
),
);
}
return false;
},
child: ListView.builder(
itemCount: edges.length,
itemBuilder: (context, index) {
final product = edges[index]['node'];
return ListTile(
title: Text(product['name']),
trailing: Text('\$${product['price']}'),
);
},
),
);
},
);
}
}
9. Ferry: Type-Safe GraphQL
Ferry generates Dart classes from
your .graphql files using build_runner. No more result.data!['products']
— you get real types with autocomplete.
# pubspec.yaml
dependencies:
ferry: ^0.15.0 # https://pub.dev/packages/ferry
gql_http_link: ^1.0.0 # https://pub.dev/packages/gql_http_link
dev_dependencies:
ferry_generator: ^0.10.0 # https://pub.dev/packages/ferry_generator
build_runner: ^2.4.0 # https://pub.dev/packages/build_runner
# build.yaml
targets:
$default:
builders:
ferry_generator:
options:
schema: lib/graphql/schema.graphql
queries_glob: lib/graphql/*.graphql
output_dir: lib/graphql/__generated__
# lib/graphql/get_products.graphql
query GetProducts($category: String, $limit: Int!) {
products(category: $category, limit: $limit) {
id
name
price
imageUrl
rating
}
}
# Generate types
dart run build_runner build --delete-conflicting-outputs
import 'package:ferry/ferry.dart';
import 'graphql/__generated__/get_products.req.gql.dart';
import 'graphql/__generated__/get_products.data.gql.dart';
class ProductListScreen extends StatelessWidget {
final Client ferryClient;
const ProductListScreen({super.key, required this.ferryClient});
@override
Widget build(BuildContext context) {
final request = GGetProductsReq((b) => b
..vars.category = 'electronics'
..vars.limit = 20);
return Operation(
client: ferryClient,
operationRequest: request,
builder: (context, response, error) {
if (response == null || response.loading) {
return const CircularProgressIndicator();
}
// Fully typed! IDE autocomplete works here.
final products = response.data?.products ?? [];
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
// product.name, product.price — all typed
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price}'),
);
},
);
},
);
}
}
10. Error Handling Patterns
GraphQL errors come in two forms: network errors (no connection, timeout) and GraphQL errors (validation failures, auth errors). Handle both:
class GraphQLException implements Exception {
final OperationException exception;
GraphQLException(this.exception);
bool get isNetworkError => exception.linkException != null;
bool get isAuthError => graphqlErrors.any(
(e) => e.extensions?['code'] == 'UNAUTHENTICATED',
);
List<GraphQLError> get graphqlErrors => exception.graphqlErrors;
String get userMessage {
if (isNetworkError) return 'No internet connection. Check your network.';
if (isAuthError) return 'Session expired. Please sign in again.';
if (graphqlErrors.isNotEmpty) return graphqlErrors.first.message;
return 'Something went wrong. Please try again.';
}
@override
String toString() => userMessage;
}
// Usage in a repository
Future<Product> getProduct(String id) async {
final result = await _client.query(
QueryOptions(
document: gql(getProductQuery),
variables: {'id': id},
),
);
if (result.hasException) {
final error = GraphQLException(result.exception!);
if (error.isAuthError) {
// Trigger re-authentication flow
await _authService.refreshToken();
return getProduct(id); // Retry once
}
throw error;
}
return Product.fromJson(result.data!['product']);
}
11. Production Tips
- Use
cacheAndNetworkfor list screens. Users see cached data instantly while fresh data loads in the background. Best perceived performance. See the GraphQL caching guide for the theory behind normalized caching. - Persist the cache. Use
HiveStoreso the cache survives app restarts. Users see data from their last session immediately. - Don't over-fetch with GraphQL. Just because you can request 50 fields doesn't mean you should. Request only what the current screen needs. Create separate queries for list views and detail views.
- Use fragments for shared fields. When multiple queries need the same fields (product cards on home, search, and category screens), define a fragment once.
- Handle WebSocket reconnection. Mobile networks are flaky. Configure
autoReconnect: trueon yourWebSocketLinkand handle the reconnection state in your UI. - Monitor query complexity. If your GraphQL server supports query complexity analysis, watch your queries. Deeply nested queries can be expensive server-side.
- Separate .graphql files. Store queries in
.graphqlfiles instead of inline strings. The GraphQL VS Code extension provides syntax highlighting, validation, and autocomplete. - Use Ferry for large projects. The upfront cost of code generation pays off when your app has
20+ queries and multiple developers. Compile-time type checking catches bugs that runtime
data!['field']access won't. - Add request logging in debug mode. Wrap your
Linkchain with a custom logging link that prints queries, variables, and response times during development.
Related Guides
- Building E-Commerce Apps with Flutter and Stripe
- GetX vs Bloc vs Riverpod: State Management Comparison 2026
- Flutter Clean Architecture: A Practical Guide
- Flutter Testing Strategy: Unit, Widget, and Integration Tests
- Flutter App Security: Protecting User Data
- Flutter Isolates & Concurrency: Background Processing Guide
- Flutter Google Maps Integration: Geolocation & Custom Markers
- Deep Linking in Flutter: Universal Links, App Links & GoRouter
- Flutter Responsive Design: Building Adaptive UIs
- Flutter Internationalization: Complete i18n & l10n Guide
Frequently Asked Questions
Should I use REST or GraphQL for my Flutter app?
Use REST when your API is simple (few endpoints, stable data shapes) and when you're working with third-party APIs that only offer REST. Use GraphQL when your app has complex data requirements — screens pulling from multiple sources, nested relationships, or real over-fetching problems. The official GraphQL docs have a good intro. Don't adopt it just because it's trendy. Let your data access patterns drive the decision.
What's the difference between graphql_flutter and Ferry?
graphql_flutter is simpler — Query/Mutation/Subscription widgets that slot right into the widget
tree, no code generation needed. Ferry uses build_runner to generate type-safe request classes
from .graphql files. Ferry gives you compile-time safety and IDE autocomplete but requires more
setup. Use graphql_flutter for smaller apps. Use Ferry when type safety matters.
How does GraphQL caching work in Flutter?
graphql_flutter uses a normalized in-memory cache. Objects with an id field get stored once —
update a user in one query and it updates everywhere that user appears. Configure fetch policies per query:
cacheFirst, networkOnly, cacheAndNetwork. For persistence across app
restarts, use HiveStore.
Can I use GraphQL subscriptions for real-time features?
Yes. Subscriptions use WebSocket connections for real-time streaming. In graphql_flutter, use
the Subscription widget with a WebSocketLink. Works well for chat, live notifications,
dashboards, and collaborative editing. Your backend needs subscription support — Apollo Server, Hasura, and
AWS AppSync all have it.
How do I handle file uploads with GraphQL in Flutter?
GraphQL doesn't natively support file uploads. Use the GraphQL Multipart Request spec (used by Apollo) with
MultipartFile from the http package. Alternatively, upload files to a storage service (S3,
Firebase Storage) via REST, get the URL back, and send that URL through a GraphQL mutation. The second
approach is usually simpler.
Is GraphQL slower than REST?
Not inherently. A well-designed GraphQL query can be faster — it fetches exactly what you need in one request instead of multiple REST calls. But complex nested queries can be expensive server-side (N+1 problem). The pagination docs cover efficient data loading patterns. Performance depends on query complexity, server implementation, and caching — not on the protocol itself.