If you’ve tried to send a file through a GraphQL API, you already know the problem: GraphQL speaks JSON, and JSON doesn’t do binary. A GraphQL upload solves this by using a multipart request specification to bundle file streams alongside your mutation — no separate REST endpoint required, no base64 bloat for standard use cases. This guide walks through every practical approach, with working code for Apollo Server, Spring Boot, and HotChocolate, so you can pick the right pattern for your project and ship it correctly.
Key Benefits at a Glance
- Simplified Architecture: Keeps file handling inside your GraphQL schema — no separate REST endpoints, no split API surface to maintain.
- Atomic Operations: Upload a file and save its metadata in a single mutation, so data and files are always in sync.
- Efficient Transfer: Multipart streaming avoids the 33% payload overhead of base64 and doesn’t load the whole file into memory.
- Broad Compatibility: Works out of the box with Apollo Client, URQL, Apollo Server, GraphQL Yoga, HotChocolate, and Spring for GraphQL.
- Strong Typing: The
Uploadscalar keeps your schema fully typed — file fields get the same validation and introspection as any other field.
Understanding the challenges of file uploads in GraphQL
GraphQL’s architecture creates a specific friction point with file uploads: the entire transport layer is built around JSON, and JSON is text-only. Binary data — images, PDFs, videos — cannot be serialized into JSON without a transformation step. That transformation step is where most of the trade-offs in GraphQL file upload strategies come from.
REST APIs sidestep this entirely. A REST endpoint can accept multipart/form-data on one route and application/json on another. GraphQL uses a single endpoint and defaults to application/json for everything, which forces a deliberate choice about how to handle binary.
| Aspect | GraphQL | REST API |
|---|---|---|
| Data Format | JSON-based queries | Multiple content types |
| Binary Handling | Not natively supported | Native multipart support |
| Transport Layer | Single endpoint | Multiple endpoints |
| Content Negotiation | Limited — requires spec extension | Built-in HTTP support |
Why GraphQL wasn’t built for file uploads
GraphQL was designed for one thing: fetching structured, relational data with precise queries. Its type system covers scalars, objects, enums, and interfaces — but not byte streams. When binary data is forced into this model via base64 encoding, the payload grows by roughly 33%, and both client and server must hold the entire encoded string in memory before processing can start. A 10 MB profile photo becomes ~13.3 MB in transit, plus memory overhead for JSON parsing on top of that.
- Base64 encoding inflates file size by ~33% before JSON overhead
- Large base64 payloads risk memory exhaustion on server and client
- No native streaming — standard JSON parsing requires full payload in memory
multipart/form-datarequests conflict with GraphQL’sapplication/jsonexpectation
Content negotiation challenges
The content-type conflict is practical, not just theoretical. GraphQL clients (Apollo, URQL, Relay) send Content-Type: application/json by default. Switching to multipart/form-data for uploads requires either client-side configuration or a separate upload link in your Apollo Client setup. On the server side, middleware must detect the content type and route the request accordingly before GraphQL execution begins.
CORS adds another layer: multipart requests with custom headers trigger a CORS preflight, whereas simple JSON requests may not. This means your upload endpoint may need explicit CORS configuration that differs from your main GraphQL endpoint.
| Request Type | Content-Type | Body Format | GraphQL Compatibility |
|---|---|---|---|
| Standard GraphQL | application/json | JSON payload | Native |
| Multipart Upload | multipart/form-data | Binary + metadata | Requires spec + middleware |
| Base64 Upload | application/json | Encoded binary in JSON | Native, but size-limited |
| Presigned URL flow | application/json (mutation) + direct | URL in JSON; binary direct to storage | Fully compatible |
Evaluating GraphQL file upload approaches
There are five practical patterns for handling file uploads in GraphQL. None of them is universally correct — the right choice depends on file size, security requirements, your infrastructure, and how much complexity you’re willing to add to the client.
| Approach | Complexity | Performance | File Size Limit | Use Case |
|---|---|---|---|---|
| Base64 Encoding | Low | Poor for large files | < 1 MB | Small files, rapid prototyping |
| Multipart HTTP | Medium | Good — supports streaming | Limited by server config | Standard file uploads |
| Separate REST Endpoint | Low | Excellent | No GraphQL limit | Large files, existing REST infra |
| Direct-to-Storage (Presigned URL) | Medium–High | Excellent — bypasses server | Storage provider limit | Cloud-native, large files |
| TokenHandler Pattern | Medium | Good | Flexible | Secure, audited uploads |
Base64 encoding: simple but limited
Base64 is the path of least resistance: embed the file as a string in your mutation variable, no server changes needed. It works with any GraphQL client and requires zero additional dependencies. The ceiling is around 1 MB — beyond that, you’re asking both sides to hold increasingly large strings in memory, and performance degrades noticeably.
- ✓ Zero additional dependencies or server config
- ✓ Works with any standard GraphQL client
- ✓ Easy to test with Playground or Postman
- ✗ 33% payload size increase from encoding overhead
- ✗ Full file must be in memory during JSON parsing
- ✗ Not viable for files larger than ~1 MB
- Hard-limit base64 uploads to 1 MB on the client before sending
- Use for avatars, thumbnails, small icons — not documents or media
- Consider gzip compression on the wire to partially offset the size penalty
When converting binary streams to Base64 strings in Java, leverage patterns from OutputStream to String conversion to handle encoding efficiently without unnecessary memory overhead.
Multipart HTTP requests: the standard approach
Multipart HTTP is the de facto standard for GraphQL file uploads. It sends the GraphQL operation (query + variables) and the file binary in separate parts of the same HTTP request, then maps the file reference to the correct mutation variable on the server. This enables streaming — the server processes the file incrementally without buffering the whole payload — and it keeps your API surface unified under GraphQL.
- Industry-standard — supported by Apollo Server, GraphQL Yoga, HotChocolate, Lighthouse
- Streaming support — handles large files without memory exhaustion
- Maintains full GraphQL type safety through proper variable mapping
- Compatible with Apollo Client’s
createUploadLinkout of the box
“Use a well-maintained implementation of the GraphQL multipart request spec. Enforce a rule that upload variables are only referenced once.”
— GraphQL.org
Source link
Multipart uploads require careful HTTP status handling. Review GraphQL HTTP status codes to ensure your server returns consistent 200/400/500 responses for upload success and failure cases — especially important when clients retry on error.
GraphQL multipart request specification
The GraphQL multipart request specification (authored by Jayden Seric) defines exactly how file parts map to GraphQL variables in a multipart request. Every major server implementation follows it, which means client libraries and server middleware are interoperable without custom negotiation.
A conforming request has three form fields: operations (the GraphQL mutation JSON, with the file variable set to null), map (a JSON object mapping each file to its variable path), and one field per file containing the binary data. The server reconstructs the complete operation with file references resolved before execution.
| Server | Spec Support | Streaming | Multiple Files |
|---|---|---|---|
| Apollo Server | Full (via graphql-upload) | Yes | Yes |
| GraphQL Yoga | Full (built-in) | Yes | Yes |
| HotChocolate (.NET) | Full (built-in) | Yes | Yes |
| Spring for GraphQL (Java) | Full (via MultipartFile) | Yes | Yes |
| Lighthouse (PHP) | Full | Yes | Yes |
Implementing GraphQL file uploads: practical examples
Below are working implementation patterns for the three most common server ecosystems. Each covers schema definition, resolver/handler code, and the key configuration options that trip people up in practice.
“Graphql server has an already built-in Upload scalar to upload files using a multipart request. It implements the following spec.”
— gqlgen.com
Source link
File uploads with Apollo Server (Node.js)
Before writing upload mutations, test your schema interactively using GraphQL Playground variables to simulate file metadata scenarios — it’s the fastest way to catch schema errors before touching client code.
Apollo Server 3+ removed built-in upload support. You need graphql-upload as explicit middleware. The key steps: add the middleware before Apollo, register the GraphQLUpload scalar, and use the Upload type in your schema.
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { GraphQLUpload, graphqlUploadExpress } = require('graphql-upload');
const app = express();
// Must be placed BEFORE Apollo middleware
app.use(graphqlUploadExpress({ maxFileSize: 10_000_000, maxFiles: 5 }));
const server = new ApolloServer({
typeDefs: `
scalar Upload
type Mutation {
uploadFile(file: Upload!): UploadResult!
}
type UploadResult {
filename: String!
mimetype: String!
url: String!
}
`,
resolvers: {
Upload: GraphQLUpload,
Mutation: {
uploadFile: async (_, { file }) => {
const { createReadStream, filename, mimetype } = await file;
const stream = createReadStream();
// Pipe stream to storage — S3, disk, etc.
const url = await saveFileToStorage(stream, filename);
return { filename, mimetype, url };
}
}
}
});
- Install:
npm install graphql-upload - Add
graphqlUploadExpressmiddleware before Apollo middleware - Register
GraphQLUploadas theUploadscalar resolver - Use
createReadStream()in your resolver — neverawaitthe whole buffer - Pipe the stream directly to storage; don’t accumulate it in memory
- Set
maxFileSizeandmaxFiles— defaults are dangerously permissive - Always validate
mimetypeagainst an allowlist in the resolver - Disable Apollo’s built-in body parser when using graphql-upload middleware
- Handle upload errors in the resolver — unhandled stream errors crash the process
File uploads with Spring for GraphQL (Java)
Spring for GraphQL (available since Spring Boot 2.7, stable in 3.x) handles multipart uploads natively through Spring MVC’s MultipartFile. You define the mutation in your .graphqls schema file and bind it to a @MutationMapping controller method. No additional upload library is needed.
# schema.graphqls
scalar Upload
type Mutation {
uploadFile(file: Upload!): UploadResult!
}
type UploadResult {
filename: String!
size: Int!
url: String!
}
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.multipart.MultipartFile;
@Controller
public class FileUploadController {
private final StorageService storageService;
public FileUploadController(StorageService storageService) {
this.storageService = storageService;
}
@MutationMapping
public UploadResult uploadFile(@Argument MultipartFile file) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("File must not be empty");
}
// Validate type before storing
String contentType = file.getContentType();
if (!List.of("image/jpeg", "image/png", "application/pdf").contains(contentType)) {
throw new IllegalArgumentException("Unsupported file type: " + contentType);
}
String url = storageService.store(file.getInputStream(), file.getOriginalFilename());
return new UploadResult(file.getOriginalFilename(), (int) file.getSize(), url);
}
}
Enable multipart handling in application.properties and register the Upload scalar:
# application.properties
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
// Register the Upload scalar — maps to MultipartFile in controllers
@Configuration
public class GraphQLConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.GraphQLUpload); // from graphql-java-extended-scalars
}
}
- Add
spring-boot-starter-graphqlandgraphql-java-extended-scalarsdependencies - Define
scalar Uploadin your.graphqlsschema file - Register
ExtendedScalars.GraphQLUploadin yourRuntimeWiringConfigurer - Use
MultipartFileas the parameter type in your@MutationMappingmethod - Set multipart size limits in
application.properties
Spring Boot’s @MutationMapping follows the same dependency injection patterns as REST controllers. If you’re splitting logic across services, see how DTOs in Spring Boot can cleanly separate your upload result model from internal domain objects.
File uploads in .NET with HotChocolate
HotChocolate handles the multipart spec natively through its IFile interface. No external middleware or scalar registration is needed — the framework maps incoming file parts to strongly-typed IFile parameters automatically.
public class Mutation
{
public async Task<UploadResult> UploadFile(IFile file, CancellationToken cancellationToken)
{
// Validate before opening the stream
if (file.Length > 10_000_000)
throw new GraphQLException("File exceeds 10 MB limit.");
await using var stream = file.OpenReadStream();
var url = await _storageService.StoreAsync(stream, file.Name, cancellationToken);
return new UploadResult(file.Name, (int)file.Length, url);
}
}
- Install
HotChocolate.AspNetCore— upload support is included - Add
.AddFilesystemTypes()or.AddUploadType()in your schema builder - Use
IFileas the parameter type directly in mutation methods - Use
OpenReadStream()and stream to storage — don’t buffer the whole file - Use
CancellationTokento handle client disconnects gracefully
Testing file upload implementations
Standard GraphQL Playground cannot send multipart requests — it only handles application/json. You need different tooling depending on whether you’re doing manual exploration or automated integration tests.
| Tool | Multipart Support | Auth Headers | Automation | Best For |
|---|---|---|---|---|
| Postman | Yes | Yes | Limited (collections) | Manual testing, quick checks |
| curl | Yes | Yes | Yes (scripted) | CI pipelines, shell scripts |
| Custom code (Jest, JUnit) | Yes | Yes | Yes — full control | Integration and regression tests |
| GraphQL Playground | No | Limited | No | Schema/query testing only |
A minimal curl command for testing a multipart upload against your local server looks like this:
curl -X POST http://localhost:4000/graphql \
-H "Authorization: Bearer YOUR_TOKEN" \
-F operations='{"query":"mutation($file:Upload!){uploadFile(file:$file){filename url}}","variables":{"file":null}}' \
-F map='{"0":["variables.file"]}' \
-F 0=@/path/to/test-file.jpg
- Test with the smallest valid file first — confirm the happy path works
- Test boundary conditions: empty file, file at exactly the size limit, file over the limit
- Test invalid MIME types — confirm the server rejects them, not just the client
- Test with authentication: valid token, expired token, missing token
- Run concurrent upload tests to verify there’s no race condition in your resolver
For validating mutation behavior beyond uploads — error messages, authorization rules — see GraphQL unit testing patterns that work alongside multipart integration tests.
Security best practices for GraphQL file uploads
File upload endpoints are a higher-value attack surface than standard GraphQL queries. The attack vectors are distinct: malicious file content, resource exhaustion from oversized uploads, CSRF against authenticated upload mutations, and path traversal if filenames are used unsanitized in storage paths.
- Always validate file type by MIME type and magic bytes — extension alone is trivially spoofed
- Enforce file size limits in middleware, not just in resolver code
- Require authentication for every upload mutation — no anonymous uploads
- Never use the client-supplied filename directly in a storage path
- Store uploaded files outside the web root or in a separate storage bucket
- Scan files with an antivirus/antimalware service before making them accessible
Combine upload validation with GraphQL authorization patterns to ensure only authenticated users can trigger file processing mutations — validation and auth should both fail fast, before the stream is read.
Preventing common vulnerabilities
| Vulnerability | Impact | Mitigation | Where to implement |
|---|---|---|---|
| Memory exhaustion | Server crash / DoS | File size limit + streaming | Middleware (before resolver) |
| Path traversal | File system access / overwrite | Sanitize filename; generate UUID-based paths | Storage service layer |
| Malicious content | Code execution / malware delivery | MIME + magic byte validation; AV scan | Resolver + async post-processing |
| CSRF | Unauthorized uploads from other origins | Custom request headers + origin validation | CORS config + auth middleware |
| Zip/XML bombs | CPU/memory exhaustion on extraction | Reject compressed payloads or scan before extraction | Resolver or storage layer |
- Validate file extension against an explicit allowlist
- Check MIME type from the
Content-Typeheader - Read the first ~16 bytes of the stream to verify magic bytes match the declared type
- Generate a random UUID for the storage key — never use the original filename
- Run an async virus scan after initial storage; mark files as “pending” until cleared
CSRF vulnerabilities in GraphQL file uploads
GraphQL’s typical CSRF protection — requiring a custom header like X-Requested-With — breaks down for multipart requests in some browsers, because multipart requests can be initiated by HTML forms without triggering a CORS preflight. This makes upload endpoints particularly vulnerable to CSRF if you rely on cookies for authentication.
- ✓ Use Bearer tokens in
Authorizationheaders — forms can’t set custom headers - ✓ Validate the
Originheader against an allowlist on every upload request - ✓ Configure CORS to restrict upload endpoints to your frontend origin
- ✓ Require re-authentication for destructive upload operations (e.g., replacing a document)
- ✗ Rely on cookies alone for upload endpoint authentication
- ✗ Allow wildcard CORS origins on upload endpoints
- ✗ Skip CSRF validation for “internal” or “trusted” clients
Performance optimization for GraphQL file uploads
The single biggest performance improvement is streaming: process the file as it arrives rather than waiting for the full upload to buffer in memory. Beyond that, the gains come from client-side behavior — progress tracking, chunking, and compression — and server-side configuration: timeouts, concurrent connection limits, and offloading to storage.
- Stream directly to S3 or GCS using a pipe — don’t write to a temp file first
- Implement chunked uploads (e.g., tus.io protocol) for files over 10 MB
- Use client-side image compression (browser-image-compression) before sending
- Set aggressive but reasonable timeouts — 30 seconds for standard files, 5 minutes for large ones
- Implement upload progress tracking with
XMLHttpRequestoraxios— not the Fetch API, which doesn’t expose upload progress - Serve uploaded files via CDN, not directly from your application server
For high-concurrency scenarios, consider offloading large uploads entirely via presigned URLs. The GraphQL mutation generates a short-lived S3 presigned URL, the client uploads directly to S3, and a separate webhook or polling mutation confirms completion. This removes the application server from the upload path entirely — your server CPU and memory are used only to generate the URL and process metadata.
When upload timeouts affect user-facing operations, apply the same timeout strategies used for GraphQL query timeouts — consistent timeout handling across mutations and queries makes debugging production incidents much faster.
Making the right choice for your project
| Project Type | Typical File Size | Security Needs | Recommended Approach |
|---|---|---|---|
| Simple CRUD app | < 1 MB | Basic | Base64 in mutation |
| Standard web app | < 100 MB | Standard | Multipart HTTP (graphql-upload / Spring) |
| Enterprise / audited app | Any size | High | Presigned URL + metadata mutation |
| Cloud-native SaaS | Large files | High | Direct-to-storage (S3 presigned) |
| Mobile app | < 50 MB | Medium | TokenHandler pattern or presigned URL |
When to avoid GraphQL for file uploads
GraphQL file upload overhead is real. For certain scenarios, it’s better to keep GraphQL for data and handle files elsewhere:
- Video uploads (>500 MB): Use direct-to-cloud or a dedicated video ingest API (Mux, Cloudflare Stream)
- Bulk document processing: REST batch APIs or async queue-based workflows outperform GraphQL mutations for high-volume ingestion
- Real-time file sync: WebSocket or WebRTC-based approaches handle connection persistence that GraphQL mutations don’t
- Simple public forms: A plain HTML form posting to a REST endpoint is less complexity with zero trade-off for public-facing, unauthenticated uploads
| Scenario | GraphQL Upload | Better Alternative | Reason |
|---|---|---|---|
| Video uploads | Poor | Direct-to-cloud (Mux, S3) | File size and streaming requirements |
| Bulk document processing | Poor | REST batch API + async queue | Processing efficiency at scale |
| Real-time file sync | Poor | WebSocket / WebRTC | Needs persistent connection |
| Simple public upload form | Overkill | Standard HTML form → REST | Complexity vs. benefit |
Hybrid architectures are often the right answer: use GraphQL for all structured data operations and mutations that return metadata, while delegating the actual binary transfer to presigned URLs or a dedicated media service. You get GraphQL’s type safety and tooling for 95% of your API, without forcing it to handle binary data at scale.
More GraphQL guides
- GraphQL HTTP Status Codes — what your server should return and when
- GraphQL Timeout — configuring and handling timeouts in mutations and queries
- GraphQL Unit Testing — strategies for testing resolvers and mutations
- GraphQL Query Unauthorised — authorization patterns and error handling
- GraphQL Validation Error — understanding and resolving schema validation failures
- GraphQL Playground Variables — using variables effectively during development
- GraphQL File Download — serving files and binary data through your API
- GraphQL Rate Limit — protecting upload and query endpoints from abuse
Frequently Asked Questions
For most applications, multipart HTTP requests following the GraphQL multipart request specification are the best approach. Libraries like graphql-upload (Node.js) or Spring for GraphQL’s built-in MultipartFile support (Java) handle the spec implementation. For larger files or cloud-native apps, generate a presigned S3 URL via a GraphQL mutation and have the client upload directly to storage — this keeps the GraphQL server out of the binary transfer path entirely.
Apollo Server 3+ removed built-in upload support, so you need the graphql-upload package. Add the graphqlUploadExpress middleware to Express before the Apollo middleware, register GraphQLUpload as the Upload scalar resolver, and define scalar Upload in your type definitions. In your resolver, the file argument resolves to an object with createReadStream, filename, and mimetype. Always stream the file to storage — don’t buffer the entire payload in memory.
Spring for GraphQL (Spring Boot 3.x) supports multipart file uploads natively. Define scalar Upload in your .graphqls schema, register ExtendedScalars.GraphQLUpload from graphql-java-extended-scalars, and use MultipartFile as the parameter type in a @MutationMapping controller method. Enable multipart in application.properties with spring.servlet.multipart.enabled=true and set appropriate size limits.
The main risks are: memory exhaustion from oversized or concurrent uploads (mitigate with size limits in middleware, not just resolvers); malicious file content such as malware or polyglot files disguised with legitimate extensions (validate MIME type and magic bytes, not just the extension); path traversal when client-supplied filenames are used in storage paths (always generate UUID-based storage keys); and CSRF against upload mutations when cookie-based auth is used (use Bearer tokens in Authorization headers instead).
The GraphQL multipart request specification defines how to bundle file uploads with GraphQL mutations in a single HTTP multipart request. A conforming request sends three form fields: operations (the mutation JSON with file variable set to null), map (a JSON object mapping each file part to its variable path), and the file binary parts. The server uses the map to resolve file references before executing the mutation. All major GraphQL server frameworks implement this spec, making it the interoperable standard for file uploads.
Use base64 only for files under 1 MB where implementation simplicity matters more than performance — avatars, thumbnails, icons. Base64 inflates payload size by ~33% and requires the full file in memory. For anything larger, use multipart HTTP requests, which support streaming and avoid the size penalty. For files over 10 MB or high-concurrency scenarios, skip both and use presigned URLs for direct-to-storage uploads.
GraphQL Playground cannot send multipart requests. Use Postman (set body to form-data, add the operations, map, and file fields manually) or curl with -F flags for manual testing. For automated testing, write integration tests using your HTTP client library (SuperTest for Node.js, MockMvc or RestAssured for Spring Boot) that construct the multipart request and assert the response. Always test with valid files, oversized files, invalid MIME types, and missing auth tokens.




