Back to blog

Migrating from REST to GraphQL in Production

the migration nobody warned me about

so we've been migrating parts of the frontend at work from REST to GraphQL. sounds straightforward right? the GraphQL gateway already exists, the schema is there, you just swap out your fetch calls for queries. easy.

it was not easy.

the n+1 trap

the first thing that bit us was the classic N+1 problem but in a way i didn't expect. with REST we had these waterfalls everywhere and honestly we'd just gotten used to them:

// Before: REST
const job = await fetch(`/api/jobs/${id}`)
const proposals = await fetch(`/api/jobs/${id}/proposals`)
const freelancers = await Promise.all(
  proposals.map((p) => fetch(`/api/users/${p.freelancerId}`))
)

the whole point of moving to GraphQL was to kill this. and it does — in theory:

// After: GraphQL (one query)
const { data } = await useQuery(gql`
  query JobDetails($id: ID!) {
    job(id: $id) {
      title
      proposals {
        freelancer {
          name
          rating
        }
      }
    }
  }
`)

one query, one round trip. beautiful. except what actually happened is that three different Vue components on the same page were each firing their own GraphQL query on mount. we replaced 8 REST calls with 3 GraphQL queries that each resolved nested data we didn't even need in two of the three components. the gateway was doing more work than before.

debouncing the mount storm

the fix was a combination of query batching and being smarter about where queries live. we moved shared data into a Pinia store with a composable that deduplicates:

// composables/useJobDetails.ts
export function useJobDetails(jobId: Ref<string>) {
  const store = useJobStore()
 
  const { onResult, loading, error } = useQuery(
    JOB_DETAILS_QUERY,
    () => ({ id: jobId.value }),
    () => ({
      enabled: !!jobId.value && !store.hasJob(jobId.value),
      fetchPolicy: store.hasJob(jobId.value) ? "cache-only" : "cache-first",
    })
  )
 
  onResult(({ data }) => {
    if (data?.job) store.setJob(data.job)
  })
 
  return {
    job: computed(() => store.getJob(jobId.value)),
    loading,
    error,
  }
}

now three components can call useJobDetails and only one query fires. the Pinia store acts as the source of truth and the enabled option prevents duplicate requests. this pattern ended up being one of the best things to come out of the migration honestly.

the 403 surprise

this one was painful. our REST endpoints had their own auth middleware — each endpoint knew what scopes it needed and the token was validated per-request. the GraphQL gateway has a different auth model. it validates the token once at the gateway level but individual resolvers have their own permission checks.

so we'd get these random 403s on fields we definitely had access to through REST. turns out the GraphQL schema had stricter scoping on some nested resolvers. a proposals field under job required a scope that the REST /api/jobs/:id/proposals endpoint didn't.

the fix was a centralized error handler that catches auth errors and triggers a token refresh:

// plugins/apollo.ts
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  if (graphQLErrors?.some((e) => e.extensions?.code === "FORBIDDEN")) {
    return fromPromise(refreshAuthToken()).flatMap(() => {
      const oldHeaders = operation.getContext().headers
      operation.setContext({
        headers: {
          ...oldHeaders,
          authorization: `Bearer ${getNewToken()}`,
        },
      })
      return forward(operation)
    })
  }
})
 
const link = ApolloLink.from([errorLink, httpLink])

this retries the failed query with a fresh token automatically. before this we had token refresh logic scattered across like six different API service files. centralizing it into the Apollo link chain was genuinely cleaner than what we had with REST.

what actually got simpler

i want to be fair here because it wasn't all pain. the things that got better:

type safety — we generate TypeScript types from the GraphQL schema now. no more guessing what shape the API response is. codegen watches the schema and spits out types that match exactly what the query returns. this alone probably saved us from a dozen bugs.

loading states — with REST we had this awkward dance of tracking multiple loading flags. with GraphQL each query gives you one loading boolean for the entire data tree. components got simpler.

error boundaries — Vue error boundaries + Apollo's error handling meant we could catch and display errors at the component level without try/catch blocks everywhere. we wrapped sections of the page in error boundaries and if one resolver fails the rest of the page still renders.

what surprised me

the hardest part wasn't the technical migration. it was changing how we think about data fetching. REST trains you to think in endpoints — "i need the job endpoint, then the proposals endpoint." GraphQL wants you to think in data shapes — "what does this component need to render?" once that clicked the code started getting cleaner on its own.

the other surprise was how much the Pinia caching layer mattered. GraphQL has its own cache (Apollo's normalized cache) but having a Pinia store on top gave us reactivity across components that Apollo's cache alone didn't handle well in Vue. maybe that's a Vue-specific thing. either way the two layers together work better than either alone.

still migrating. still finding edge cases. but the patterns are solid now and new pages go up faster than they did with REST. i'll take it.