background-shape
GraphQL vs REST in 2024, An Honest Take After Five Years
July 1, 2024 · 6 min read · by Muhammad Amal programming

TL;DR — GraphQL still wins for client-driven aggregation and BFF layers. REST still wins for cacheable, resource-shaped public APIs. The middle ground is wider than you think, and the right answer in 2024 is usually “both, scoped”.

I have shipped both styles in production, on teams ranging from three engineers to forty. Five years ago I thought GraphQL was going to eat REST. It didn’t, and the reasons are interesting. They’re not about technology. They’re about caching, observability, the shape of your org, and how much your clients change.

What follows is not a benchmark post. It is the heuristic I actually use when a new service lands on my desk and someone asks “should this be GraphQL or REST”.

The short answer is that the question is wrong. The real question is who controls the client, how often it ships, and where you want your aggregation layer. Get those right and the protocol falls out of the answer.

Where REST still wins in 2024

REST over HTTP/1.1 or HTTP/2 has one massive advantage that GraphQL evangelists keep underselling: every layer between your client and your handler understands it. CDNs cache it. Load balancers route by path. WAFs inspect it. Browsers handle conditional requests with If-None-Match for free.

If your API is public, versioned, and resource-shaped, you want REST. Stripe and GitHub did not pick REST because they hadn’t heard of GraphQL. They picked it because Cache-Control: max-age=300 on GET /v1/customers/cus_123 is a one-line latency improvement you cannot replicate cleanly in GraphQL without a persisted query layer and a cache that understands your schema.

Here’s a typical Go REST handler with proper cache semantics:

package customers

import (
    "encoding/json"
    "fmt"
    "net/http"
    "strconv"
)

func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // Go 1.22 ServeMux
    c, err := h.store.Find(r.Context(), id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    etag := fmt.Sprintf(`"%s-%d"`, c.ID, c.Version)
    if match := r.Header.Get("If-None-Match"); match == etag {
        w.WriteHeader(http.StatusNotModified)
        return
    }

    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "private, max-age=60")
    w.Header().Set("Content-Length", strconv.Itoa(c.Size()))
    _ = json.NewEncoder(w).Encode(c)
}

Go 1.22’s ServeMux with method+path patterns finally makes vanilla REST handlers in stdlib pleasant. I have stopped reaching for chi or gorilla on small services entirely. The Go 1.22 release notes cover the routing changes in detail.

Where GraphQL still wins

The argument for GraphQL in 2024 is the same as it was in 2019, and it’s a real one: the client picks the shape of the response. If you have a mobile app and a web app with different needs, hitting the same backend, GraphQL removes a whole class of “we need another REST endpoint that returns customer + last invoice + open tickets” tickets. Those tickets compound.

The second argument, which gets less airtime, is the schema-first workflow. A .graphql file is a contract that both your TypeScript codegen and your Go resolvers consume. There is no swagger-fight, no JSON Schema versus OpenAPI argument. Just one source of truth.

type Customer {
  id: ID!
  email: String!
  invoices(first: Int = 10): InvoiceConnection!
  openTickets: [Ticket!]!
}

type Query {
  customer(id: ID!): Customer
}

That single block of SDL, combined with gqlgen or graph-gophers, gives you typed Go resolvers and typed TS hooks. The development feedback loop is genuinely faster for client-driven aggregation work.

For multi-team backends, federation is the killer feature, which is why I wrote a separate piece on federated GraphQL with Apollo Router — it’s worth reading if you have more than one team owning data behind the same API.

The N+1 question, finally

Every GraphQL post mentions N+1. Most of them stop at “use dataloader”. That is correct but unhelpful. In 2024, here’s what I actually do:

  1. Every resolver that crosses a network boundary takes a batched loader.
  2. Loaders are constructed per-request, attached to context, never reused.
  3. The loader’s batch function is the only place that talks to the database or downstream service.
func (r *customerResolver) Invoices(ctx context.Context, obj *Customer) ([]*Invoice, error) {
    loaders := dataloader.For(ctx)
    return loaders.InvoiceByCustomer.Load(ctx, obj.ID)()
}

If you skip this and just call your repository in the resolver, you will hit production with a query that fans out to 200 SQL statements per page load. I have seen this in three separate audits. Always.

REST, for what it’s worth, has its own N+1 problem at the client layer — the app makes one call per item — but it surfaces in client tracing, not server load. Pick your poison.

Caching, the real difference

This is where I think most architecture posts get it wrong. The choice between GraphQL and REST in 2024 is mostly a choice about where you cache.

REST caches in infrastructure you already own: CDNs, reverse proxies, browsers. GraphQL caches in application code: persisted queries, automatic persisted queries (APQ), Apollo Router’s response cache, or a Redis layer keyed by query hash plus variables.

Both work. The REST path is cheaper to operate. The GraphQL path is more expressive — you can cache subtrees, invalidate by entity type, do partial responses. If your team has the operational maturity for the second, GraphQL caching is better. If they don’t, you’re going to regret it at 3am.

Apollo Router 1.48 ships with a built-in response cache that works at the entity level. It is the first time I’ve felt the GraphQL-side caching story is actually competitive operationally, not just on paper.

What I do now

After all this, here is the rule I actually follow:

  • Public API, third-party developers, resource-shaped → REST + OpenAPI. No exceptions.
  • Internal service-to-service, single team owns both ends → gRPC. Not GraphQL, not REST. Different post.
  • Mobile/web client, multiple backends, fast UI iteration → GraphQL as a BFF, federated if multiple teams.
  • Admin tools, internal dashboards → REST. They don’t iterate enough to justify the schema layer.

The boring answer is that you’ll probably have all three in any company larger than 30 engineers, and that is fine. Stop trying to standardize.

Common Pitfalls

A few things I have watched teams trip on in the last two years:

  • Using GraphQL for public APIs. Your customers want curl, Postman, and Cache-Control. They don’t want a query language. Stripe knows this.
  • Skipping persisted queries. If you ship GraphQL to a mobile client without persisted queries, you have built an open query endpoint that lets attackers send query { users { friends { friends { friends { ... } } } } }. Use APQ or persisted queries from day one.
  • Resolver-level auth checks. Centralize auth in middleware or directives. Per-resolver if user.Role != "admin" checks rot fast.
  • REST without versioning strategy. Pick /v1/ in the path or Accept: application/vnd.x.v1+json in the header. Both work. Pick one before you ship.
  • Treating GraphQL as a database query language. It’s an API contract. Don’t expose your ORM through it.

Wrapping Up

The protocol war is over and nobody won. GraphQL didn’t replace REST. REST didn’t make GraphQL irrelevant. They solve different problems and most production systems benefit from a clear-eyed split.

The 2024 version of this debate is less about “which is better” and more about “where does aggregation live, and who owns the client”. Once you can answer those two questions for a given surface, the choice is mechanical.

If you’re starting fresh and you have to pick one, my default in 2024 is still REST with OpenAPI for the boring 80% — see my piece on OpenAPI first API design in Go with oapi-codegen — and GraphQL only where the BFF pattern earns its keep. Reach for federation when you have at least three teams and a real coordination cost, not before.