Huzi Blogs
BlogCategories
BlogCategories
Disclaimer & Data Privacy Policy
Project by huzi.pk

© 2026 blogs.huzi.pk. All Rights Reserved.

    Back to all posts
    Web Development

    GraphQL vs REST API Design 2026: The Complete Problem-Solving Guide

    By Huzi

    Your mobile team files a ticket: the order-detail screen makes three round-trips — /orders/123, then /users/456, then /products?ids=7,8,9 — and on a 3G connection the screen paints in 2.4 seconds, blank the whole way. REST gives you clean resources but forces the client to know the graph of relations; GraphQL lets the client ask once, in a single query, for the order, its customer, and the line-item products — and the server responds with exactly the fields the UI needs, no more, no less. The catch is that this power moves complexity from the client into the server, where N+1 queries, complexity attacks, and cache invalidation live. Choosing between REST and GraphQL in 2026 is not a religion — it is a tool choice driven by your data shape, your client diversity, and your team's operational maturity. Here is the complete playbook, with copy-paste TypeScript, for picking the right tool and running it well.

    ⚖️ 1. When to Use Each

    Pick REST for Simple CRUD and Public APIs: REST shines when your domain maps cleanly to resources, your clients are predictable, and you want every HTTP cache, proxy, and CDN to work without custom logic. A public API consumed by third-party developers is almost always better as REST because the world already understands GET /v1/orders/123, status codes are self-documenting, and curl works on day one. File-heavy operations — uploads, downloads, streaming media — also belong in REST because multipart handling in GraphQL is bolted-on and awkward.

    Pick GraphQL for Nested Data and Multiple Clients: GraphQL pays for itself the moment you have two clients with different data needs — a web dashboard that wants everything, a mobile app that wants a sliver, an internal tool that wants aggregated counts. The BFF (Backend for Frontend) pattern, where each client gets a tailored GraphQL gateway over shared services, is the canonical 2026 use case. If your screen needs data from four microservices, GraphQL Federation can stitch them into one query from the client's perspective.

    The 2026 Decision Table:

    Situation Choose Why
    Public API, third-party consumers REST Discoverability, HTTP caching, tooling
    Simple CRUD on one entity REST GraphQL overhead is not earned
    Mobile + web with different fields GraphQL Single query, no over-fetching
    Aggregated dashboard across microservices GraphQL + Federation One query, one round-trip
    File uploads / streaming REST Multipart is native
    Real-time subscriptions GraphQL Subscriptions or WebSockets Both work; pick by stack
    Internal typed RPC between services tRPC or gRPC End-to-end types, no schema ceremony

    The tRPC, gRPC, and Federation Landscape: tRPC has eaten the "Next.js full-stack typed RPC" niche — if your client and server share TypeScript, tRPC gives you end-to-end types with zero codegen. gRPC remains the standard for internal service-to-service calls where protobuf contracts and bidirectional streaming matter. GraphQL Federation (Apollo Federation 2) is the answer when you have multiple teams owning different domains but want one graph for clients. None of these replace REST for public APIs — they each fill a niche.

    📐 2. REST Best Practices

    Version from Day One: Put the version in the URL (/v1/, /v2/) for public APIs because it is the only strategy that survives incompetent clients — header-based versioning breaks the moment a client library strips unknown headers. Bump the major version on breaking changes (renaming fields, changing semantics), and add new fields without bumping because adding is non-breaking. Emit Deprecation and Sunset headers on routes you intend to retire so monitoring can flag stragglers.

    // Express route with deprecation header
    app.get('/v1/orders/:id', async (req, res) => {
      res.set('Deprecation', 'true');
      res.set('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT');
      res.set('Link', '</v2/orders/123>; rel="successor-version"');
      const order = await db.order.findById(req.params.id);
      res.json(order);
    });
    

    Cursor Pagination, Not Offset: Offset-based pagination (?page=5&limit=20) breaks when rows are inserted or deleted between requests — the user sees duplicates or skips. Cursor pagination uses an opaque token encoding the last-seen row's sort key, so the next page always starts exactly where the previous one ended. Return a cursor in the response body, not a header, so clients can serialize and resume.

    // Cursor-based pagination response shape
    {
      "data": [{ "id": "ord_7421", "createdAt": "2026-03-01T10:00:00Z" }],
      "pagination": {
        "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAzLTAxVDEwOjAwOjAwWiIsImlkIjoib3JkXzc0MjEifQ==",
        "hasMore": true
      }
    }
    

    RFC 7807 Problem Details for Errors: Stop inventing custom error shapes — RFC 7807 gives you a standard JSON object with type, title, status, detail, and instance, and most API gateways and client libraries understand it natively in 2026. Use the right HTTP status code (400 for bad input, 401 for unauthenticated, 403 for forbidden, 404 for missing, 409 for conflict, 422 for validation, 429 for rate-limited, 500 for server fault) and let the body explain.

    // RFC 7807 error response
    function problem(res: Response, status: number, title: string, detail: string) {
      res.status(status).json({
        type: 'https://errors.example.com/validation',
        title,
        status,
        detail,
        instance: '/v1/orders'
      });
    }
    // Usage
    problem(res, 422, 'Validation failed', 'amount must be positive');
    

    Status Codes Are a Contract: 200 for success, 201 for created (with Location header pointing at the new resource), 204 for success with no body, 400 for malformed input, 401 for missing or bad auth, 403 for authenticated but not allowed, 404 for missing, 409 for state conflict, 422 for well-formed but invalid, 429 for rate limit, 500 for unhandled server error. Do not return 200 with an error body — that breaks every monitoring tool and client library on earth.

    This series is supported by HTG Travels.

    🔷 3. GraphQL Schema Design

    Design the Schema First, Resolvers Second: The schema is your contract with every client — design it like a public API, with clear types, sensible naming, and nullability that reflects reality. Mark fields nullable only when they actually can be null; a User.email that is always present should be String!, not String, so clients do not write defensive null checks. Model relations as fields on types, not as separate query roots — post.author reads better than authorForPost(postId).

    type User {
      id: ID!
      email: String!
      name: String!
      posts(limit: Int = 10): [Post!]!
    }
    
    type Post {
      id: ID!
      title: String!
      body: String!
      author: User!
      comments(limit: Int = 20): [Comment!]!
    }
    
    type Comment {
      id: ID!
      body: String!
      author: User!
    }
    
    type Query {
      user(id: ID!): User
      posts(limit: Int = 10, after: String): PostConnection!
    }
    
    type Mutation {
      createPost(input: CreatePostInput!): CreatePostPayload!
    }
    
    input CreatePostInput {
      title: String!
      body: String!
    }
    
    type CreatePostPayload {
      post: Post
      errors: [UserError!]!
    }
    
    type UserError {
      field: String
      message: String!
    }
    

    Resolvers Are Just Functions: A resolver takes (parent, args, context, info) and returns a value or a Promise. The top-level Query resolvers receive null as parent; field resolvers on User receive the parent User object. Keep resolvers thin — they should delegate to a service layer, not contain business logic, so your schema and your domain stay decoupled.

    import { ApolloServer } from '@apollo/server';
    import { startStandaloneServer } from '@apollo/server/standalone';
    
    const resolvers = {
      Query: {
        user: async (_: any, { id }: { id: string }, { dataSources }: Context) =>
          dataSources.users.getById(id),
        posts: async (_: any, { limit, after }: any, { dataSources }: Context) =>
          dataSources.posts.list({ limit, after })
      },
      User: {
        posts: async (parent: { id: string }, { limit }: { limit: number }, ctx: Context) =>
          ctx.dataSources.posts.byAuthor(parent.id, limit)
      },
      Post: {
        author: async (parent: { authorId: string }, _: any, ctx: Context) =>
          ctx.dataSources.users.getById(parent.authorId)
      },
      Mutation: {
        createPost: async (_: any, { input }: any, ctx: Context) => {
          if (!ctx.user) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } });
          const post = await ctx.dataSources.posts.create({ ...input, authorId: ctx.user.id });
          return { post };
        }
      }
    };
    
    const server = new ApolloServer({ typeDefs, resolvers });
    const { url } = await startStandaloneServer(server, {
      context: async ({ req }) => ({ user: await verifyJwt(req.headers.authorization), dataSources })
    });
    

    Mutations Return Payloads, Not Bare Types: The CreatePostPayload pattern — returning a wrapper with the entity plus an errors array — is what lets you handle partial failures and validation errors without throwing. This is the same shape GitHub's GraphQL API has used for years, and it scales.

    🔗 4. The N+1 Problem & DataLoader

    The Trap: When a query asks for posts { author { name } }, a naive resolver fires one SQL query for the posts, then one query per post to fetch its author — 1 query for the list plus N queries for the relations, hence "N+1." With 50 posts on a page, that is 51 database round-trips instead of 1. This is the single most common GraphQL performance bug, and it will turn a 50ms query into a 2-second one.

    DataLoader Batches the Lookups: DataLoader, originally from Facebook, collects all author lookups in a single event-loop tick and batches them into one WHERE id IN (...) query. Each load(authorId) call returns a Promise; at the end of the tick, DataLoader calls your batch function with the full list of IDs, you issue one query, and the Promises resolve.

    import DataLoader from 'dataloader';
    
    // Batch function: given a list of author IDs, return a list of users in the same order
    async function batchUsers(ids: readonly string[]): Promise<User[]> {
      const rows = await db.users.findMany({ where: { id: { in: ids as string[] } } });
      // DataLoader requires the returned array to be in the same order as the input IDs
      const byId = new Map(rows.map(r => [r.id, r]));
      return ids.map(id => byId.get(id) ?? null);
    }
    
    // Create one DataLoader per request (in the context) so caching is request-scoped
    const context = (): Context => ({
      loaders: {
        users: new DataLoader(batchUsers, { cacheKeyFn: String })
      }
    });
    
    // Resolver uses load() instead of direct DB call
    const resolvers = {
      Post: {
        author: async (parent: { authorId: string }, _: any, ctx: Context) =>
          ctx.loaders.users.load(parent.authorId)
      }
    };
    

    Request-Scoped Caching Is the Key: DataLoader caches by key within a single request, so if posts { author } and comments { author } both reference the same user, that user is fetched exactly once across the entire query. Never make DataLoaders singletons — request-scoped instantiation is mandatory for correctness, otherwise you leak user data between requests.

    🔐 5. Authentication & Authorization

    REST: JWT in the Authorization Header: The standard pattern is a Bearer token in the Authorization header, validated by middleware that attaches the decoded user to req.user. Use short-lived access tokens (15 minutes) and long-lived refresh tokens (7-30 days) rotated on use; never store access tokens in localStorage if you can avoid it — use HttpOnly cookies with SameSite=Lax.

    import jwt from 'jsonwebtoken';
    
    function authMiddleware(req: Request, res: Response, next: NextFunction) {
      const header = req.headers.authorization;
      if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'Missing token' });
      try {
        req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET!) as JwtPayload;
        next();
      } catch {
        res.status(401).json({ error: 'Invalid token' });
      }
    }
    
    // Role check helper
    function requireRole(role: string) {
      return (req: Request, res: Response, next: NextFunction) => {
        if (req.user?.role !== role) return res.status(403).json({ error: 'Forbidden' });
        next();
      };
    }
    
    app.delete('/v1/orders/:id', authMiddleware, requireRole('admin'), deleteOrder);
    

    GraphQL: Context-Based Auth Plus Field Directives: GraphQL auth splits into two layers — operation-level auth in the context (is this user logged in at all?) and field-level auth in resolvers or directives (is this user allowed to see this field?). The context runs once per request and is the right place to verify the JWT; resolvers then check ctx.user for fine-grained permissions.

    // Context: verify once per request
    const context = async ({ req }): Promise<Context> => {
      const user = req.headers.authorization
        ? await verifyJwt(req.headers.authorization)
        : null;
      return { user, dataSources };
    };
    
    // Field-level guard in a resolver
    const resolvers = {
      User: {
        email: (parent: User, _: any, ctx: Context) => {
          if (ctx.user?.id !== parent.id && ctx.user?.role !== 'admin') return null;
          return parent.email;
        }
      }
    };
    

    Custom Directives for Declarative Auth: For schema-wide rules, a @auth(requires: ADMIN) directive is cleaner than scattering checks across resolvers. Apollo Server 4 supports this via mapSchema and a custom directive visitor — the directive intercepts field resolution and throws UNAUTHENTICATED before the resolver runs. This is the same pattern GitHub's API uses for @requireAdmin.

    🗄️ 6. Caching Strategies

    REST: HTTP Cache Headers Are Free: REST gets caching almost for free because every HTTP cache on the planet — browser, CDN, corporate proxy — understands Cache-Control, ETag, and Last-Modified. Set Cache-Control: public, max-age=300 on cacheable responses, private for user-specific data, and use ETag with If-None-Match to return 304 Not Modified when nothing changed. A CDN in front of a REST API can absorb 80% of read traffic with zero application code.

    app.get('/v1/products/:id', async (req, res) => {
      const product = await db.products.findById(req.params.id);
      res.set('Cache-Control', 'public, max-age=300, s-maxage=3600');
      res.set('ETag', `"${product.version}"`);
      if (req.headers['if-none-match'] === `"${product.version}"`) {
        return res.status(304).end();
      }
      res.json(product);
    });
    

    GraphQL: Cache in the Client, Be Careful at the Edge: GraphQL's single-endpoint POST design defeats HTTP caching by default — every request is a POST with a body, so CDNs cannot key on the URL. The standard answer is Apollo Client's normalized cache on the client (which deduplicates User:1 across queries automatically), plus persisted queries on the server (which hash the query and let the client send just the hash, enabling GET requests and CDN caching).

    // Apollo Client normalized cache
    import { InMemoryCache, ApolloClient, HttpLink } from '@apollo/client';
    
    const client = new ApolloClient({
      link: new HttpLink({ uri: '/graphql' }),
      cache: new InMemoryCache({
        typePolicies: {
          Query: {
            fields: {
              posts: relayStylePagination()
            }
          }
        }
      })
    });
    
    // Server-side: persisted queries (Apollo Server supports this out of the box)
    // Client sends ?extensions={"persistedQuery":{"sha256Hash":"abc...","version":1}}
    // Server responds with the cached query plan, and the GET request is CDN-cacheable
    

    Cache Invalidation Is the Hard Part: REST invalidation is coarse — purge /v1/products/:id and you are done. GraphQL's normalized cache means a single mutation can invalidate many query results, but you must tell the client to refetch or use cache.modify to evict the right fields. There is no free lunch: the more powerful the cache, the more careful you must be about staleness.

    ⚠️ 7. Common Pitfalls & Migration

    GraphQL Complexity Attacks: A malicious client can request posts { author { posts { author { posts { ... } } } } } and recursively blow up your server. The fix is two layers: query depth limiting (reject queries deeper than 7 levels) and cost analysis (assign a cost to each field, reject queries whose total cost exceeds a budget). Libraries like graphql-depth-limit and graphql-cost-analysis plug into Apollo Server as validation rules.

    import depthLimit from 'graphql-depth-limit';
    import costAnalysis from 'graphql-cost-analysis';
    
    const server = new ApolloServer({
      typeDefs,
      resolvers,
      validationRules: [
        depthLimit(7),
        costAnalysis({
          maximumCost: 1000,
          defaultCost: 1,
          onComplete(cost: number) {
            if (cost > 800) console.warn(`Expensive query: ${cost}`);
          }
        })
      ]
    });
    

    REST Versioning Hell: If you have /v1/, /v2/, and /v3/ of the same endpoint all in production, you have a deprecation problem, not a versioning problem. Fix it with explicit Deprecation headers, sunset dates, and a quarterly audit that deletes endpoints past their sunset. Charge internal teams a "deprecated API tax" if you must — make keeping the old version around more painful than migrating.

    File Uploads in GraphQL: The GraphQL multipart spec (graphql-multipart-request-spec) works but is awkward — it splits the query and the files into a multipart body that most client libraries handle only with custom code. The pragmatic 2026 answer is to keep file uploads in REST (or a dedicated upload service returning presigned URLs), and use GraphQL for the metadata. S3 presigned URLs are the cleanest pattern: the client asks GraphQL for a presigned URL, uploads directly to S3, then tells GraphQL the upload is done.

    // GraphQL mutation that mints a presigned URL
    const resolvers = {
      Mutation: {
        createUploadUrl: async (_: any, { filename, contentType }: any, ctx: Context) => {
          if (!ctx.user) throw new GraphQLError('Unauthorized');
          const key = `uploads/${ctx.user.id}/${uuid()}-${filename}`;
          const url = await s3.getSignedUrlPromise('putObject', {
            Bucket: process.env.S3_BUCKET!,
            Key: key,
            ContentType: contentType,
            Expires: 300
          });
          return { url, key };
        }
      }
    };
    

    Migrating from REST to GraphQL: Do not big-bang it. Stand up an Apollo Server alongside your REST API, expose one GraphQL query that wraps existing REST endpoints (the BFF pattern), and migrate clients screen by screen. Measure the round-trip count and payload size before and after — the GraphQL version should win on both. If it does not, you have over-fetched the schema and need to split types. Sponsors like HTG Travels keep series like this free.

    🙋 Frequently Asked Questions

    Should I use GraphQL or REST for a brand-new project in 2026? Default to REST for public APIs, simple CRUD, or file-heavy services. Choose GraphQL when you have multiple clients with different data needs, nested/aggregated data, or multiple teams owning domains that should look like one graph to clients. If you are a solo TypeScript full-stack dev, tRPC is often the right third option — end-to-end types with no schema ceremony.

    How do I fix the N+1 problem in GraphQL? Use DataLoader for every relation that hits a database or external service. Create DataLoaders per-request in the GraphQL context (never as singletons), and call loader.load(id) in resolvers instead of fetching directly. DataLoader batches all loads in a single event-loop tick into one WHERE id IN (...) query and caches by key within the request.

    Is GraphQL slower than REST? For simple single-resource lookups, yes — GraphQL adds parsing, validation, and resolver overhead. For screens that need data from multiple resources, GraphQL is usually faster because it collapses N round-trips into 1. The bottleneck is almost always the database, not the protocol, and DataLoader is the lever that keeps GraphQL fast at scale.

    How do I cache a GraphQL API at the CDN edge? Enable persisted queries so clients can send a GET request with a query hash, then configure your CDN to cache those GET responses by URL. Set short Cache-Control max-age values for public queries and use Apollo Client's normalized cache on the client to deduplicate entities. For per-user data, do not CDN-cache — use the client cache only.

    When should I use GraphQL Federation instead of a monolithic schema? When you have multiple teams (say, 5+ engineers per team) owning different domains — orders, payments, users — and you want one unified graph for clients without forcing one team to deploy another's code. Federation lets each team own a subgraph and the gateway composes them. For a team under 10 engineers, a monolithic Apollo Server is simpler and faster to operate.

    🔚 Final Word

    GraphQL and REST are not competitors — they are tools for different jobs, and the best teams in 2026 use both in the same product. REST for public APIs, file uploads, and CDN-cacheable reads; GraphQL for mobile BFFs, dashboards that aggregate across services, and any screen where round-trip count is the bottleneck. The 2026 landscape adds tRPC for typed full-stack RPC and gRPC for internal service-to-service contracts, but those are niche picks, not replacements.

    The 80/20 of API design: version from day one in REST, use DataLoader in GraphQL always, return RFC 7807 errors in REST and GraphQLError with extensions in GraphQL, validate JWT in the context not the resolver, and never let a GraphQL query run without depth and cost limits. Get those five right and you will outoperate most teams — including, unfortunately, several who have raised more than you.

    The remaining 20% — Federation composition, persisted queries, cursor pagination, presigned uploads, custom auth directives — is where the senior work lives. Measure round-trips before you migrate, suspect the database before the protocol, and remember that the cheapest performance upgrade in 2026 is still a well-placed DataLoader.

    🇵🇸 Standing With Palestine

    Never let the world forget the people of Palestine. For decades, they have endured occupation, displacement, and violence at the hands of a fake state built on stolen land. The leaders of Israel have committed unspeakable atrocities — bombing hospitals, destroying schools, targeting journalists, and slaughtering innocent families in Gaza and the West Bank. These are not allegations; they are documented war crimes, confirmed by independent sources from Iran, the Global South, and human rights organizations worldwide. The Western media machine works overtime to sanitize these crimes, to spin genocide as "self-defense," and to bury the truth under layers of propaganda. But the truth cannot be buried. Palestine is real. Palestinian suffering is real. And the world is watching.

    They will be free. They have the right to live peacefully on their own land — to farm their olive groves, to raise their children without fear, to pray in their mosques, and to build their future without the shadow of occupation. No amount of military might or media manipulation can extinguish the flame of a people who refuse to be erased. May Allah help them and grant them justice. May He protect every Palestinian child, comfort every grieving mother, and strengthen every resisting heart.

    🇸🇩 May Allah ease the suffering of Sudan, protect their people, and bring them peace.

    Written by Huzi

    Advertisements


    You Might Also Like

    High-Quality Digital Print Lawn Suit 3-Piece with Diamond Voil Dupatta (90/70)

    High-Quality Digital Print Lawn Suit 3-Piece with Diamond Voil Dupatta (90/70)

    PKR 3900

    Heavy Black Velvet Wedding Dress – Zari/Nag Embroidery, Chiffon Dupatta

    Heavy Black Velvet Wedding Dress – Zari/Nag Embroidery, Chiffon Dupatta

    PKR 7900

    Luxury Heavy Embroidered Velvet Wedding Suit 2026 | Tie & Die Organza Dupatta & Raw Silk Trouser

    Luxury Heavy Embroidered Velvet Wedding Suit 2026 | Tie & Die Organza Dupatta & Raw Silk Trouser

    PKR 8800

    Embroidered Lawn Suit 3-Pc (Blue) | Digital Print Diamond Lawn Dupatta (2025)

    Embroidered Lawn Suit 3-Pc (Blue) | Digital Print Diamond Lawn Dupatta (2025)

    PKR 4700

    Trendy Digital Print 90/70 Lawn 2-Pc Suit with Printed Trouser (Spring/Summer 2025)

    Trendy Digital Print 90/70 Lawn 2-Pc Suit with Printed Trouser (Spring/Summer 2025)

    PKR 2950

    Advertisements


    Related Posts

    Web Development
    WebSockets Real-Time Apps 2026: The Complete Problem-Solving Guide
    From chat apps to live dashboards, here is the complete 2026 WebSockets playbook — Socket.io vs raw WS, scaling with Redis adapter, JWT auth, reconnection, and real-world examples with copy-paste code.

    By Huzi

    Read More
    Web Development
    API Gateway Patterns 2026: The Complete Problem-Solving Guide
    Routing, aggregation, auth offloading, rate limiting, BFF pattern, and Kong vs AWS vs Cloudflare. Here is the complete 2026 API Gateway playbook with real-world setup and copy-paste configs.

    By Huzi

    Read More
    Web Development
    Next.js 16 Server Actions: The Complete 2026 Problem-Solving Guide
    Server Actions replace API routes for form mutations in Next.js 16. Here is the complete problem-solving guide — useActionState, useOptimistic, Zod validation, revalidation, file uploads, authentication, and real-world CRUD examples with copy-paste code.

    By Huzi

    Read More
    Web Development
    Vite + React Performance 2026: The Complete Problem-Solving Guide
    Core Web Vitals, code splitting, React.lazy, React Compiler, useTransition, list virtualization, and bundle analysis. Here is the complete 2026 Vite + React performance playbook with before/after examples.

    By Huzi

    Read More
    Web Development
    Web Dev Frameworks in 2026: Next.js 16 vs Astro 5 vs SvelteKit 2 vs Remix
    Next.js 16 still owns 65% of new React projects, Astro 5 owns content, SvelteKit 2 + Svelte 5 Runes owns performance, and Remix has merged into React Router v7. Here is the 2026 comparison every Pakistani developer needs — features, use cases, deployment costs, and real cases like Maqsad and Edufa.

    By Huzi

    Read More
    Web Development
    Getting Started with React 19: A Beginner's Guide for 2026
    A beginner-friendly introduction to React 19 in 2026. Learn what React is, why it dominates modern web development, and how to build your first React component with the new useActionState and useOptimistic hooks.

    By Huzi

    Read More