GraphQL file uploads - evaluating the 5 most common approaches


Editor's Note: This post walks through several GraphQL file upload approaches, and our main focus is WunderGraph Cosmo , designed to revolutionize API and GraphQL management. As our flagship product, Cosmo empowers teams by streamlining and elevating their API workflows, making it an essential solution for modern GraphQL Federation. If you're exploring GraphQL Federation solutions, explore the key features of WunderGraph Cosmo to see how it can transform your approach.
If you're on GraphQL Federation, the Cosmo Router handles file uploads for you. It implements the GraphQL multipart request specification, so clients send files as multipart/form-data and the router routes each file to the subgraph that resolves the mutation. You turn the feature on or off, cap the file size, and cap how many files a request can carry, all in the router config. If you want to see how it fits your setup, book a time with us.
Recommended approach: For most production systems, use direct-to-storage uploads with server-side authorization, and only use GraphQL to request or confirm the upload. If you're already running GraphQL Federation, the Cosmo Router handles multipart uploads natively and enforces size and file-count limits centrally, so you don't have to build a custom upload transport.
TL;DR
Best answer: For production, direct-to-storage uploads or a server-mediated upload pattern are the safest options and the ones that scale best. Base64 is only good for quick prototypes, multipart is useful but non-standard, and a separate REST API adds extra protocol and auth work. If you run GraphQL Federation, the Cosmo Router handles multipart uploads natively, with size and file-count limits set in the router config.
Serving structured data is the core of GraphQL. Send a Query to the server, and you get a JSON Object back with exactly the structure you were asking for. What about files though? How do files fit into a Query Language for Data?
It's a common theme that starters are confused when they are asked to upload a JPEG or PDF file using GraphQL. Out of the box, there's nothing in the GraphQL specification that mentions files. So, what are the options available and when should we choose which one?
Let's start with an overview of the different options:
- Base64-encoded GraphQL mutations.
- Multipart GraphQL mutations.
- A separate REST API for uploads.
- Direct uploads to S3.
- Native multipart file uploads through the Cosmo Router.
Throughout the post, you'll learn that - base64 encoded blobs is the simplest solution with some drawbacks - mutations with multipart HTTP Requests is the most complex one - using a separate REST API can be a clean solution but is unnecessary - because S3 is already the perfect API to upload files, it's just not ideal to directly expose it - and if you're on Federation, the Cosmo Router handles multipart uploads for you without a custom transport
Before we dive into evaluating the different solutions, let's establish some metrics for "good" solutions:
- complexity of implementation on both client and server
- bandwidth overhead should be minimal
- uploads should be fast
- the solution should work across different languages and client- and server frameworks
- portability: it should work on your laptop as well as in the cloud
- no vendor lock in
- we should be able to easily make uploads secure
- once an upload is finished, we should be able to run server-side code
Let's start with the simplest solution, encoding the file as a base64 encoded blob.
StackOverflow has an example for us on how it works:
This reads a file and returns it as a base64 encoded string. You might be asking why base64 at all? The reason is, you cannot just send a file as part of a string. A JSON Object, which is used to send GraphQL Requests, is a string. If we want to send a file as part of this JSON Object, we first have to turn it into a text representation.
Ok, we understand the how and the why, let's see if this is a good solution.
The complexity of the implementation, as you can see above, is low. On the server side, you decode the JSON and then turn the base64 encoded string into its binary format again.
But there are a few problems with this solution. Base64 encoding increases the size of the file by roughly one third. So, instead of uploading 3 Megabytes, you have to upload 4. This doesn't scale well, especially not for large files.
Keep in mind that base64 encoded files are part of the enclosing JSON object. This means, you're not able to "stream" this base64 string through a decoder and into a file. Uploading one gigabyte of data using this method would result in one gigabyte occupied memory on the server.
If you're looking for a quick and dirty solution, it's a great choice. For production environments where a lot of API clients upload files, it's not a good match though.
Alright, we've learned that encoding files to ASCII is a quick solution but doesn't scale well. How about sending files in binary format? That's what HTTP Multipart Requests are meant for.
The Cosmo Router implements this as a first-class feature, following the GraphQL multipart request specification. See GraphQL file upload support in the Cosmo Router for configuration details.
Let's have a look at a Multipart Request to understand what's going on:
A HTTP Multipart request can contain multiple "parts" separated by a boundary. Each part can have additional "Content-*" Headers followed by the body.
How to create a MultiPart Request from JavaScript?
It's simple, right? Take a (fake) list of Files, append all of them to the FormData Object and pass it to fetch as the body. JavaScript takes care of the boundaries, etc...
On the backend, you have to read all individual parts of the body and process them. You could send a dedicated part for the GraphQL Operation and additional parts for attached files.
Let's first talk about the benefits of this solution. We're sending the files not as ASCII text but in the binary format, saving a lot of bandwidth and upload time.
But what about the complexity of the implementation? While the client implementation looks straight forward, what about the server?
Unfortunately, multipart uploads are not part of the core GraphQL specification, so support depends on the client and server implementation. This means, your solution will not be easily portable across different languages or implementations and your client implementation depends on the exact implementation of the server.
Without Multipart, any GraphQL client can talk to any GraphQL server. All parties agree that the protocol is GraphQL, so all these implementations are compatible. If you're using a non-standard way of doing GraphQL over Multipart HTTP Requests, you're losing this flexibility.
Next, how will your GraphQL client handle the Request? Do you have to add a custom middleware to rewrite a regular HTTP Request into a Multipart one? Is it easy to accomplish this with your GraphQL client of choice?
Another problem I see is that you have to limit the number of Operations that allow Multipart Requests. Should it be allowed for Queries and Subscriptions? Probably not. Should it be allowed for all Mutations? No, just for some of them, or even just for a single Mutation, the one to upload files. To handle this, you have to add custom logic to your GraphQL Server. This logic will make portability more complex as you'd have to re-implement this logic in another language.
Finally, you have the file as part of the Multipart Request. Where do you store it? That's another problem you have to solve. S3 is probably your best option if it should work both locally and in the cloud.
So, in terms of implementation complexity, this solution is quite heavy and has a lot of open questions.
Maybe it's simpler to just use a dedicated REST API?
This sounds like a solid idea. Instead of tightly coupling a custom GraphQL client to our custom GraphQL server, we could also just add a REST API to handle file uploads.
We use the same concepts as before, uploading the files using a Multipart Request.
Then, from the REST API handler, we take the files and upload them to S3 and return the response to the client.
With this solution, we're not tightly coupling a custom GraphQL client to our custom GraphQL server implementation as we leave the GraphQL protocol as is.
This solution is also fast and there's not much of a bandwidth overhead. It's also easily portable as we've not invented a custom GraphQL transport.
What are the tradeoffs though?
For one, authentication is an issue. If we're deploying the upload API as a second service, we have to find a solution that allows us to authenticate users across both the GraphQL and the REST API. If, instead, we're adding the REST API alongside the GraphQL API, just on a different endpoint, we're losing on portability again, but it's not as big of an issue as adding Multipart directly to the GraphQL API.
Another issue is complexity, We're establishing a custom protocol between client and server. We have to implement and maintain both of them. If we'd like to add another client to our implementation, using a different language, we're not able to use an off-the-shelf GraphQL client and call it a day. We'd have to add this extra piece of code to the client to make it work.
In the end, we're just wrapping S3. Why not just use S3 directly?
State of GraphQL Federation 2026
How are teams governing schema changes, handling production traffic, and measuring Federation success? Share your experience and get early access to the full report. For every valid survey completed, we'll donate $30 to UNICEF .
One of the issues of our custom solution is that we're establishing a custom protocol for uploading files. How about relying on an established protocol? How about just using S3? There are plenty of clients in all languages available.
With this approach, the GraphQL API stays untouched, and we're not inventing custom file upload protocols. We can use off-the-shelf GraphQL clients as well as standard S3 clients. It's a clear separation of concerns.
Well, there's another tradeoff. How do we do authentication?
Most guides suggest adding custom backend code to pre-sign upload URLs so that users from insecure environments, e.g. the Browser, are able to upload files without the need of a custom Authentication Middleware.
This adds some complexity, but it's doable. You could even add this logic as a Mutation to our GraphQL Schema. With this approach, the user can first create an attachment with metadata, which then returns a pre-signed URL to upload the file.
However, this leads to another problem. How do you know if the file was actually uploaded? You probably want to add some custom business logic to check S3 periodically if the file is successfully uploaded. If this is the case, you can update the attachment metadata in the GraphQL API.
Another issue with pre-signed S3 URLs is that file-size enforcement typically requires additional controls, such as signed policies or server-side validation. Attackers could easily spam you with big files and exhaust your storage limits.
Additionally, do you really want your API clients to directly talk to an API of the storage provider? From a security point of view, wouldn't it make more sense to not have them interact directly?
To sum it up, a dedicated S3 API comes with a lot of advantages over the previously discussed solutions, but it's still not the perfect solution. We can make it work, but it needs custom solutions to make it secure, validate the files are actually uploaded and to prevent large uploads.
Use base64 for quick prototypes, multipart if you accept client/server coupling, REST if you want a separate upload service, direct S3 if you want the simplest path that scales, and the Cosmo Router if you're on Federation and want native multipart uploads without building the transport yourself.
Looking at all the options we've discussed so far, we're able to make a wish list to guide us to the ultimate solution.
Base64 encoding files is out. The increase in upload bandwidth doesn't justify the simplicity. We definitely want to use Multipart file uploads. But we don't want to hand-roll a custom transport into our GraphQL API, and we don't want our clients coupled to a bespoke server implementation. Separating the file transport from the data layer makes sense. And we don't want to invent our own protocol just to upload files alongside standard GraphQL clients.
If you're running GraphQL Federation, the Cosmo Router already does this.
The router implements the GraphQL multipart request specification , the de facto community standard for uploading files over GraphQL. It's the same multipart approach from earlier in the post, but you don't invent the transport or write custom middleware. Any client that speaks the spec works against it.
Here's how it fits together.
First, you declare an Upload scalar in your subgraph schema and use it on the mutations that accept files:
Only operations that declare an Upload accept files. So which operations take uploads is decided in the schema, not in custom server logic.
The client then sends a standard multipart request. Here's a single upload with curl:
The router accepts the request and routes the file to the subgraph that resolves the mutation. Single and multiple file uploads are both supported. Batch uploads are not.
Limits live in the router config, not in your resolvers:
File upload is enabled by default. The default maximum file size is 50MB, and the default maximum number of files per request is 2. So you cap size and count in one place instead of policing every mutation by hand.
What's left is to evaluate this against the criteria we established at the beginning.
Complexity is low. There's no custom transport and no upload middleware. You declare the Upload scalar and wire the mutation in your subgraph, and the router handles the rest.
There's no bandwidth overhead, because it's multipart in binary format, not base64.
Portability is good. It's the published multipart request spec, so standard clients work and there's no bespoke protocol to reimplement per language.
Storage stays where it belongs. The router handles transport, not storage. Your subgraph receives the file and does whatever it needs, whether that's S3, a database, or local disk.
For security, the size and file-count limits are enforced at the router, so a client can't spam you with oversized files or an unbounded number of them.
Full details on the multipart request structure and every config option are in the file upload docs.
The bottom line: GraphQL should orchestrate the upload, not carry the file payload itself.
I hope it's clear that uploading files for web applications is not as easy as it might sound.
Depending on your use case, the simple base64 approach might work well for you.
Hand-rolling your own multipart protocol into your GraphQL API should really be avoided, it adds a lot of complexity. If you're on Federation, let the Cosmo Router do multipart for you instead.
A custom REST API might be a good solution if you have the resources to build it.
If you're already running GraphQL Federation, the Cosmo Router gives you binary multipart uploads with central size and file-count limits, and no custom transport to maintain.
See the file upload docs for the full multipart request structure and router configuration.
Frequently Asked Questions (FAQ)
Base64 encoding increases a file's size by roughly one third, so a 3 MB file becomes a 4 MB upload. Because the encoded string is part of the enclosing JSON object, you can't stream it through a decoder into a file, so uploading one gigabyte would occupy one gigabyte of memory on the server. It works for a quick solution, but it does not hold up in production where many clients upload files.
GraphQL has no standard for handling multipart requests, so the solution is not portable across languages or implementations and the client ends up coupled to the exact server implementation. You also lose the interoperability where any GraphQL client can talk to any GraphQL server, and you need custom server logic to restrict which operations accept multipart uploads.
For most production systems, direct-to-storage uploads or a server-mediated upload pattern are the best options because they keep uploads scalable and controllable.
In most cases, GraphQL should coordinate the upload rather than carry the file itself. Direct-to-storage is usually better for performance and scalability.
No. Multipart uploads are widely used as a community convention, but they are not part of the core GraphQL specification.
Base64 increases file size, adds CPU overhead, and does not stream efficiently, so it does not scale well for large uploads.
Use server-side authorization, upload limits, and storage-backed workflows that keep credentials off the client.
CEO & Co-Founder at WunderGraph
Jens Neuse is the CEO and one of the co-founders of WunderGraph, where he builds scalable API infrastructure with a focus on federation and AI-native workflows. Formerly an engineer at Tyk Technologies, he created graphql-go-tools, now widely used in the open source community. Jens designed the original WunderGraph SDK and led its evolution into Cosmo, an open-source federation platform adopted by global enterprises. He writes about systems design, organizational structure, and how Conway's Law shapes API architecture.

