the migration nobody warned me about
so we've been migrating parts of the frontend at work from REST to GraphQL. the GraphQL gateway already exists, the schema is there, you just swap out your fetch calls for queries.
it was not easy.
the n+1 trap
the first thing that bit us was the N+1 problem, but not the way i expected. with REST we had waterfalls everywhere and 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. except 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
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 the best thing to come out of the migration.
the 403 surprise
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.
we wrote 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 six different API service files. centralizing it into the Apollo link chain was cleaner than what we had with REST.
what actually got simpler
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 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.
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. 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.
