GraphQL requests are JSON, and JSON has no binary type. Uploading a file therefore needs one of three strategies: a multipart request that the server understands, a server framework with built-in file support, or keeping files out of GraphQL entirely with signed URLs. After this lesson you will be able to implement each and choose the right one.
The community "GraphQL multipart request" specification defines how to send an operation plus files in a single multipart/form-data request. The graphql-upload package implements it for Node servers and provides the Upload scalar:
npm install graphql-uploadimport { GraphQLUpload, graphqlUploadExpress } from "graphql-upload";
const typeDefs = `#graphql
scalar Upload
type Mutation { uploadAvatar(file: Upload!): User! }
`;
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadAvatar: async (_p, { file }, { user, storage, db }) => {
const { filename, mimetype, createReadStream } = await file;
if (!mimetype.startsWith("image/")) throw new GraphQLError("Images only");
const key = `avatars/${user.id}-${Date.now()}-${filename}`;
await storage.put(key, createReadStream());
return db.users.update(user.id, { avatarKey: key });
},
},
};
app.use("/graphql", graphqlUploadExpress({ maxFileSize: 5_000_000, maxFiles: 3 }), expressMiddleware(server));The argument resolves to a promise of an object with createReadStream(), so the file is streamed rather than buffered in memory. Two details matter with Apollo Server: the upload middleware must run before expressMiddleware, and because multipart requests are a CSRF risk, Apollo's default csrfPrevention requires clients to send the header Apollo-Require-Preflight: true. On the client, createUploadLink from apollo-upload-client replaces HttpLink and sends File objects from a form automatically.
GraphQL Yoga implements the same specification without extra packages and exposes files as standard File objects:
const yoga = createYoga({
schema: createSchema({
typeDefs: `scalar File type Mutation { uploadAvatar(file: File!): Boolean! }`,
resolvers: {
Mutation: {
uploadAvatar: async (_p, { file }) => {
const bytes = await file.arrayBuffer();
await storage.put(file.name, Buffer.from(bytes));
return true;
},
},
},
}),
});file.name, file.type, file.size, file.stream() and file.arrayBuffer() are the same properties available in browsers.
Large files through the API server cost bandwidth, memory and request time. The pattern most production teams use is to let GraphQL hand out permission and let the client upload straight to object storage:
type Mutation {
createUploadUrl(filename: String!, contentType: String!): UploadTicket!
attachAvatar(key: String!): User!
}
type UploadTicket { url: String!, key: String! }The client calls createUploadUrl, sends the file with a plain PUT to the returned pre-signed URL (S3, Cloudflare R2 and Google Cloud Storage support these), then calls attachAvatar with the key. GraphQL never touches the bytes, the API stays JSON-only, and storage handles size limits and virus scanning hooks.
| Approach | Best for | Trade-off |
|---|---|---|
| graphql-upload | Small files, existing Apollo and Express stack | Extra middleware, CSRF header, files pass through the API |
| Yoga File scalar | Small files on Yoga | Files still pass through the API |
| Signed URLs | Production, large files, mobile | Two round trips, storage-specific setup |
await file followed by buffering; stream to storage instead.maxFileSize and maxFiles limits, which lets a single client exhaust the server.mimetype; check magic bytes server-side when it matters.Why does Apollo Server reject multipart uploads unless a special header is present?
graphql-upload adds a streamable Upload scalar; register its middleware before Apollo's and send Apollo-Require-Preflight: true.File scalar with the standard browser File API.Next lesson: Consuming GraphQL from a Frontend — send operations from a browser with fetch and understand what a client library adds.