Guide

A Comprehensive Guide To RESTful Simplicity vs GraphQL Flexibility

Zachary Kirby
Co-Founder
Published On
August 9, 2023

REST APIs rule the world. Pretty much every service on the modern web relies on REST APIs.

Yet, as the web grows more complex and the demand for more efficient data handling increases, REST APIs, with their unassuming simplicity, begin to show some strain. While they have proven their worth in myriad applications, providing a reliable way to create, read, update, and delete data, they also have their share of limitations. Over-fetching, under-fetching, and the time-consuming necessity to hit multiple endpoints for intricate data needs are just a few examples.

Enter GraphQL: a powerful alternative that offers not just a way to fetch data, but a complete query language that revolutionizes how clients ask for and receive information. Unlike REST, GraphQL allows clients to dictate exactly what data they need, reducing unnecessary network requests and providing immense flexibility. Yet, this power comes with its own complexities and learning curve. The question then isn't about whether REST or GraphQL is universally better, but about which tool is right for the job at hand.

What are REST APIs?

The key idea behind REST APIs is simplicity.

REST APIs were designed as a response to the standard APIs of the time (the late 1990s), such as SOAP APIs. SOAP (Simple Object Access Protocol) APIs worked well for exchanging data between the enterprise software systems of the day. But as the web started to dominate software and tech, SOAP just wasn’t a viable solution.

SOAP isn’t simple. It is based on a simple idea, XML, but it requires a substantial amount of XML configuration. It can be difficult to write and understand, leading to longer development times and greater possibility for errors.

It also wasn’t optimized for the web. It has poor performance as SOAP uses XML exclusively for its message format. XML is a verbose and heavy data format, resulting in large message sizes. This can lead to network, storage, and processing overhead, which in turn can result in poor performance and higher latency. It is also not particularly browser-friendly. SOAP is not designed to use a URL as a means to directly access resources. This makes it less compatible with modern web technologies, as it can't leverage native browser features like caching.

So in 2000, in his doctoral dissertation, computer scientist Roy Fielding introduced the idea of REST APIs, an architectural style for distributed hypermedia systems. REST stands for REpresentational State Transfer, which means we are transferring the state of ‘representations’ from one place to another (a client to a server, and vice versa), where a ‘representation’ basically means some type of data that can be encoded, such as a customer record or a blog post.

This was a simpler, more efficient means of creating APIs. It is simpler because:

You should read the dissertation if you have time, but here are the other key components of REST APIs Fielding laid out:

An additional simplicity factor is that the HTTP methods REST APIs use map well to the create, read, update, and delete (CRUD) operations on resources in databases. This makes it extremely easy for developers to control data through creating REST APIs.

Let’s show how simple these REST APIs can be, starting with a GET request. We’re use the JSONPlaceholder API, which is a simple fake REST API for testing and prototyping:

async function getPosts() {
    try {
        const response = await fetch('https://jsonplaceholder.typicode.com/posts');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

getPosts();

In this example, getPosts is an async GET call that fetches posts from the JSONPlaceholder API. When called, it waits for the fetch function to complete, then waits for the response to be converted to JSON, and then logs the data to the console. GET is the only REST API that doesn’t require any additional data to be sent to the server.

Here's an example of a POST request:

async function createPost() {
    try {
        const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
            method: 'POST',
            body: JSON.stringify({
                title: 'My New Post',
                body: 'This is the content of my new post.',
                userId: 1
            }),
            headers: {
                'Content-type': 'application/json; charset=UTF-8'
            }
        });
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

createPost();

In this example, createPost is a POST function that creates a new post on the JSONPlaceholder API. It does this by passing an options object as the second argument to the fetch function, which specifies the HTTP method to use (POST), the body of the request (a JSON string containing the new post's data), and the headers for the request (to specify the content type of the request body).

PUT and PATCH are two HTTP methods that are commonly used for updating resources in REST APIs. However, they are used in slightly different ways:

Here’s some code to illustrate the differences:

async function updateUserPut() {
    try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1', {
            method: 'PUT',
            body: JSON.stringify({
                name: 'Updated User',
                email: 'updateduser@example.com'
                // other fields left out intentionally
            }),
            headers: {
                'Content-type': 'application/json; charset=UTF-8'
            }
        });
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

updateUserPut();

In this PUT example, we're updating the user with ID 1. Only the name and email fields are supplied. All other fields will be set to their default values or cleared.

Here’s a PATCH:

async function updateUserPatch() {
    try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1', {
            method: 'PATCH',
            body: JSON.stringify({
                email: 'updateduser@example.com'
            }),
            headers: {
                'Content-type': 'application/json; charset=UTF-8'
            }
        });
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

updateUserPatch();

In this PATCH example, we're only updating the email field of the user with ID 1. All other fields will retain their current values.

A DELETE request obviously deletes data:

async function deleteUser() {
    try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1', {
            method: 'DELETE'
        });

        // A successful DELETE request is expected to return HTTP status 200
        if (response.status == 200) {
            console.log('User deleted successfully');
        } else {
            console.log('Failed to delete the user');
        }
    } catch (error) {
        console.error('Error:', error);
    }
}

deleteUser();

In this example, deleteUser is a function that sends a DELETE request to delete the user with ID 1. It does this by passing an options object as the second argument to the fetch function, which specifies the HTTP method to use ('DELETE').

That’s it. REST APIs really are that simple. Obviously, different implementations of them can lead to massively different data needing to be sent to the APIs in the body, but you are doing everything with just these five types of request.

Overall, the good things about REST APIs are:

But they have drawbacks. These mainly come from the simplicity of REST API design. Since the user has little control over what data is returned, it can lead to overfetching data or underfetching data.

Overfetching happens when the client downloads more information than it actually needs. In a REST API, endpoints usually return a fixed data structure. If the client only needs a portion of the data, there's no easy way to tell the server to send only that required data.

Let's say we have a REST API for a blog, and we want to display a list of blog post titles. The endpoint /api/posts might return something like this for each post:

{
   "id": 1,
   "title": "My first blog post",
   "content": "Lots of content...",
   "author": {
       "id": 1,
       "name": "John Doe",
       "email": "john@example.com"
   }
 }

But to display the list, we only need the id and title of each post. All the other data (content, author id, name, email) is not used, which means we're overfetching data.

Underfetching occurs when an API endpoint doesn’t provide enough of the required information. The client has to make additional requests to fetch everything it needs.

Suppose we have an endpoint /api/posts that only returns the id and title of each post:

{
   "id": 1,
   "title": "My first blog post"
}

Now, if we want to display the full content of a blog post along with the author's name, we have to make additional requests to /api/posts/:id for the content and possibly /api/authors/:id for the author's name. This is underfetching, which can result in multiple round-trips to the server and negatively impact performance.

First, the client makes a GET request to /api/posts to get a list of all posts. The server responds with something like:

[
   {
       "id": 1,
       "title": "My first blog post"
   },
   {
       "id": 2,
       "title": "My second blog post"
   }
]

The user clicks on the first post. Now, the client has to make another GET request, this time to /api/posts/1, to get the full content of the post. The server responds with something like:

{
   "id": 1,
   "title": "My first blog post",
   "content": "Lots of content...",
   "authorId": 1
}

Now we have the post content, but we still don't have the author's name and email. So the client has to make a third GET request, this time to /api/authors/1. The server responds with something like:

{
   "id": 1,
   "name": "John Doe",
   "email": "john@example.com"
}

So, to display one blog post with its author information, the client has to make three separate requests.

This leads to performance issues, especially when dealing with a large number of resources or when the client has a slow network connection.

And this is exactly what GraphQL was built to address.

What are GraphQL APIs?

GraphQL is a query language for APIs and a runtime for executing those queries with your existing data, developed internally by Facebook in 2012 before being publicly released in 2015.

The primary advantage of GraphQL is that it allows clients to define the structure of the responses they want to receive. This prevents the overfetching and underfetching seen in REST APIs.

Here are some key concepts in GraphQL:

There are also two other important parts of GraphQL. Resolvers define the instructions for turning a GraphQL operation into data and help the GraphQL server to know how to find the data for each field in a query. Subscriptions are real-time updates in GraphQL. When a query is made with a subscription, the server sends a response to the client whenever a particular event happens, akin to webhooks. We won’t cover these here as they are more advanced concepts in GraphQL.

Let’s start with the schema. Here's an example of a GraphQL schema, for a simple blog application. We can also see the type system at play:

type Query {
    posts: [Post]
    post(id: ID!): Post
    author(id: ID!): Author
}

type Mutation {
    createPost(title: String!, content: String!, authorId: ID!): Post
    updatePost(id: ID!, title: String, content: String): Post
    deletePost(id: ID!): Post
}

type Post {
    id: ID!
    title: String
    content: String
    author: Author
}

type Author {
    id: ID!
    name: String
    email: String
    posts: [Post]
}

In this schema:

This schema provides a strong contract for the client-server communication, and any data queries or mutations sent by a client will be validated and executed against this schema.

Let’s work through an example with a hypothetical GraphQL API endpoint at https://myapi.com/graphql. We’ll start with the GET request equivalent, a query:

async function getPosts() {
    const query = `
        query {
            posts {
                id
                title
                content
                author {
                    id
                    name
                    email
                }
            }
        }
    `;

    try {
        const response = await fetch('https://myapi.com/graphql', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query })
        });

        const { data } = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

getPosts();

This will query the database for the id, title, content, and author of all the posts.

Aside: Even though it's a different type of operation, the HTTP method is still POST for all GraphQL requests. This is because the type of operation is encoded in the body of the request, not the HTTP method. Another option for communicating with a GraphQL endpoint is to use a specific library, such as Apollo Client, that is optimized for GraphQL.

Now let’s use a Mutation. Here we’re going to use createPost and pass it the title, the content, and an authorId:

async function createPost() {
    const mutation = `
        mutation {
            addPost(title: "New Post", content: "New Content", authorId: "1") {
                id
                title
                content
            }
        }
    `;

    try {
        const response = await fetch('https://myapi.com/graphql', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query: mutation })
        });

        const { data } = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

createPost();

We can use the updatePost mutation to update the title and content of the Post with id 1:

async function updatePost() {
    const mutation = `
        mutation {
            updatePost(id: "1", title: "Updated Title", content: "Updated Content") {
                id
                title
                content
            }
        }
    `;

    try {
        const response = await fetch('https://myapi.com/graphql', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query: mutation })
        });

        const { data } = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

updatePost();

Whereas with REST APIs you have two different methods, PUT and PATCH to perform a full update or a partial update, respectively, GraphQL isn’t based on HTTP methods so these aren’t available. Instead, in GraphQL, whether a field is updated or not, depends on if you include it in the mutation. We have to write separate mutations for these, such as this mutation to just update the title:

async function updatePostTitle() {
    const query = `
        mutation {
            updatePost(id: "1", input: {title: "Updated Post Title"}) {
                id
                title
                content
            }
        }
    `;

    try {
        const response = await fetch('https://myapi.com/graphql', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query })
        });

        const { data } = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

updatePostTitle();

Finally, we can use the deletePost mutation:

async function deletePost() {
    const mutation = `
        mutation {
            deletePost(id: "1") {
                id
            }
        }
    `;

    try {
        const response = await fetch('https://myapi.com/graphql', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query: mutation })
        });

        const { data } = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

deletePost();

With these few examples, you can start to see the flexibility of GraphQL. You can ask for any data you need without having to call multiple endpoints or do round-trips. You can also write the exact mutations you need for your CRUD applications.

So, GraphQL is the future, right?

Why REST is almost always going to be the right answer

We can see from these examples some of the advantages of GraphQL over REST. When you make a mutation, you can specify the data you want to get back. This allows you to retrieve the updated data in the same request as the mutation–no need for another GET request to grab the data you only just added or changed, so fewer round-trips. This means you get Goldilocks fetching–not too much, not too little. Just right.

Two other bonuses of GraphQL are:

BUT. All this comes at the cost of complexity. GraphQL is complex because:

Because REST APIs rely on in-built HTTP methods, effectively the complexity of networking is already abstracted away for you. This means developers can focus on defining the endpoints and data structures, without having to worry about the underlying communication protocol. These HTTP methods (the GET, POST, PUT, PATCH, DELETE above) provide a simple and intuitive way to manage data on the server and map naturally to database operations (create, read, update, delete), which further reduces complexity.

Moreover, the status codes provided by HTTP offer a ready-made, standardized way of communicating the result of a request - success, failure, or something in between - without having to design a separate system for this. In GraphQL, every response returns a 200 OK status code, and errors are handled within the response body. Developers must manually parse the response to check if any errors were encountered during data retrieval or mutation.

Finally, the stateless nature of REST, where each request is independent and does not require knowledge of previous requests, simplifies the server design and scalability. There's no need to maintain, track, or manage sessions on the server side; each request contains all the information needed to process it.

So when to use which? Use GraphQL when:

For everything else, there’s REST. Use REST when:

The power of simplicity

It may seem that the flexibility of GraphQL makes it much more powerful than REST. But simplicity brings its own power. Using REST allows you to build APIs quickly and simply, meaning developers can spend more time on the important parts of your application, like the business logic.

At Vessel, we use REST APIs for this purpose. We’re designing for developer experience–we want to make building integrations as simple as possible. So not only are we building a single, universal API for all of your do-to-market tools, but we also only use REST APIs to keep even this simple integration simple (we even simplify it further, and only use POST methods for our endpoints. No GETs, PATCHes, PUTs, or DELETEs).

Simplicity allows your engineering team to focus on creating the most valuable features, improving product quality, and innovating. By utilizing the power of REST's simplicity, you expedite the process, streamline workflows, and ultimately, bring greater value to your users.