GraphQL can’t do that…

We deal with lots of files in our web application. Customer logos, financial reports, and invoices, spreadsheets, the list goes on. On the other hand, our GraphQL API doesn’t have a clue what a file is. GraphQL is built for serving structured data, not large streams of raw bytes. There isn’t a “best” way to serve files over GraphQL, so we took the time to evaluate a few solutions and ended up with an implementation that works well for our needs.

We expect to grow the number of features in our application as well as our customer count, and we can reasonably expect the number of files we need to serve to be somewhat correlated with both of those factors. We did not want to be solving the files problem again and again every few months as our needs changed, so we wanted to end up with something that could scale alongside that growth with minimal changes.

Alternatives

Bare bones

Our first (and simplest) solution served files directly through our GraphQL API and stored the contents directly in our PostgreSQL database. This also meant providing the file contents as a Base64-encoded string in the GraphQL response. This was a quick and easy way to get off the ground with files in GraphQL, but was not going to be a good long-term solution.

This approach presents a few operational challenges. Serving Base64 files would require managing an API that serves mostly small JSON payloads, but still needs to have the capacity to serve or accept large file payloads. Storing potentially very large binary data in PostgreSQL leads to an inflated table and slow queries.

Inflated

Separate files service

Instead of forcing our API service to deal with heterogeneous response sizes, we could have built a dedicated GraphQL file service. This works in principle, but most client GraphQL querying libraries don’t work well (or at all) with multiple GraphQL endpoints. So in addition to the two services needed to serve files and the rest of our API, we’d need a third service to stitch them together and provide a unified API to our client. We just went from one API service to three! We have to operate anything that we build, and we don’t want to end up having more microservices to operate than team members.

Multipart GraphQL mutation

The solutions above solve some of the complexity involved with uploading files over GraphQL, but they still leave room for some failure cases. GraphQL mutations happen over a single POST request; failures, retries, and lack of concurrency could become problematic. There is existing technology for shuttling bytes around the internet in the form of web browsers, and we want to rely on that technology as much as possible. Multipart file uploads in GraphQL are an existing solution, and solve these issues in some capacity.

Shuttle

We considered layering some kind of multipart protocol on top of a series of GraphQL mutations, which would allow us to upload our files in chunks and take advantage of parallel uploads and reduced overhead. This solves some of the issues we would have had at the API level, but doesn’t present a good solution for serving files, and still doesn’t provide a solution for storing the file contents.

Solution

We took a step back and thought about what we wanted the end state to be. We knew that we didn’t want to host the files ourselves - we definitely wanted a cloud provider to handle the undifferentiated heavy lifting. We run on AWS, and S3 is an excellent service.

Considering a single file in our app, we knew that after an upload we wanted the contents of that file to end up somewhere in S3. We aren’t doing any operations on the bytes in the file as they come through, so our server doesn’t need to be involved in the actual upload process. So we would just be adding redundant compute at that point. We do want to track some other data about files and be able to relate those files to other objects, but metadata is exactly the kind of thing our API is already built to handle - small JSON payloads!

Redundant

Instead, we can ask our server to initiate a file upload with signed upload URLs from an external file service, give those URLs back to the client over GraphQL, and have the client do the uploading. This way, the client & file service handle retries, overhead, etc. Whenever the client is done, it simply informs our server over GraphQL that the upload is complete. Then, we’ll have an uploaded file, and a metadata record in our database that we can reference.

Querying

We’ve already established that we want to offload as much of this from our server as possible. In addition to uploading directly to our file service, we can also have our blob store serve the files for us. Ideally, we want a tight integration with our GraphQL API, so that our client can request file URLs for the resources it needs. Consider a customer object, which might have a logo that we want to display.

query Customer {
  customer {
    id
    logo_url
  }
}

To serve this request, we can simply get the URL for a file after it has been uploaded, and store it in our database on a customer record. This works very well for files that can have a stable, static URL. However, we don’t want this for every file. Some files are sensitive, like financial reports, so we instead want to use signed URLs.

We can’t store a signed URL in our database, because it would probably expire before it is queried again! But we can safely keep records of the file metadata, and allow the client to request a signed URL as needed. For files like this, we expose the file ID as an object property, and then allow an authorized client to request a URL for the corresponding file in S3.

query URL($id: ID!) {
  urlForFile(id: $id) {
    url
  }
}

Mutating

We want the server to do as little work as possible during the upload, and we’ve designed the process to offload the work to the client and external service. First, our client initiates an upload via a GraphQL mutation.

mutation StartFileUpload($count: Int!) {
  startFileUpload(count: $count) {
    id
    urls
  }
}

The mutation accepts some file metadata, and most importantly the number of file parts the client wants to upload. Our server initiates a file upload with our external file service, and sends the upload URLs back to the client in the urls property of the mutation result. Additionally, the server returns a file ID which allows us to track the in-progress upload.

When the client receives the mutation result, it begins slicing the file into parts, and uploading each part to S3 using the URLs. This lets us define any upload-specific code where it is relevant (our client and S3) and keep our server out of the loop! After the last part is uploaded, the client then informs our server that the upload is complete via another mutation.

mutation CompleteFileUpload($id: ID!) {
  completeFileUpload(id: $id) {
    success
  }
}

Conclusion

The solution we’ve built checks the boxes we needed; our server doesn’t have to handle file contents, and we can still use our single GraphQL API. We can rely on our external file service to serve files so that we don’t have to. We can also take advantage of existing web technology to handle uploading files directly to an external provider and avoid the operational complexity involved in managing large payloads of raw bytes. We let GraphQL do what it was meant to do, and let AWS do the heavy lifting.