skip to content

When would you take file bytes out of a GraphQL API and use signed upload URLs instead?

level: seniorimportance: should knowfreq 41%

answer

  1. Ask what the request path is carrying
  2. Small and atomic versus large and resumable
  3. Where is the authorization decision made
  4. The URL is itself a credential
  5. Two phases mean two failure states

basics

~20 s

When files are large, numerous, or crossing many hops on the way in. A mutation mints a short-lived signed URL, the client uploads straight to storage, and a second mutation attaches the stored object by reference.

solid answer

~50 s

The multipart request convention buys **atomicity**: file and metadata arrive in one mutation, authorized by the graph's normal field authorization, validated together, committed together. Pay for that with small files. It stops paying when the bytes are large or numerous, when the graph fronts many services and something in the path only speaks JSON, or when uploads must survive a dropped connection. Signed URLs move the bytes out: a mutation authorizes the upload and returns a short-lived URL scoped to one storage key and content type, the client sends the bytes directly to storage, and a second mutation attaches the object. The graph stays a JSON-only API and file size stops being its problem. The costs are real — three round trips, a two-phase lifecycle with an orphan window, constraints that must be baked into the signature and re-verified against the stored object afterwards, and scanning that now happens out of band.

code

graphql · 15 lines
graphql
type Mutation {
  requestAssetUpload(
    accessionId: ID!
    contentType: String!
    byteSize: Int!
  ): AssetUploadTicket!

  attachAsset(accessionId: ID!, uploadId: ID!): CollectionObject!
}

type AssetUploadTicket {
  uploadId: ID!
  uploadUrl: String!
  expiresAt: String!
}

go deeper

for a junior

Recall that the two routes exist and roughly what each costs: one request that carries the file, versus three requests where the bytes go straight to storage. Knowing the shape of the second flow is enough at this level.

for a middle

Explain the mechanics: which mutation authorizes the upload, why the signed URL must be narrow and short-lived, and why the server verifies the stored object rather than the client's declarations. Be able to name a size at which you would switch.

for a senior

Demonstrate that you have operated this. Name the orphan window and how you close it, make the attach idempotent, and show you know a signed URL is a bearer credential. Be ready for the racing-notification failure and how to make the pipeline order-independent.

for a principal

Own the policy across teams: the threshold, who enforces it, and whether one exceptional non-JSON request path across many services is worth carrying at all. Weigh the cleanup machinery a two-phase flow adds against the request-path cost it removes.

## What changes when bytes go through the graph With the multipart convention, every hop between the client and the resolver carries the file. In a single-service graph that is unremarkable. In an 11-service graph it is a design decision with consequences at each hop: a body that is no longer JSON, a request that occupies a connection and a request slot for the duration of the transfer, and a composition layer in front of the subgraphs — a federation router, say — that generally forwards JSON and has to be specially arranged to forward anything else. Uploads become the one request shape that is exceptional everywhere. ## When the multipart route is right Keep the bytes in the graph when the file is small and its meaning is inseparable from its metadata. A 240 KB condition photo attached to an accession record is the clean case: one round trip, one authorization decision made by the same field authorization as every other mutation, one transaction, and no state in which the photo exists but the condition report that explains it does not. No new lifecycle, no second service to reason about, no cleanup job. ## When to move them out Switch when any of these is true: - **Size.** A 380 MB master scan should not occupy a request slot on a service whose job is resolving fields, and should not be sized against request handling that exists for JSON. - **Reliability.** A transfer that takes tens of seconds over a bad link needs retry and resume. Storage services offer that natively; a mutation does not, and a retried mutation is a repeated upload. - **Path length.** The more services and intermediaries in front of the graph, the more places a non-JSON body has to be understood. Direct-to-storage collapses that to one hop. - **Locality.** Direct upload lands at an ingest endpoint or a CDN edge near the client rather than at wherever the graph happens to run. ## The shape of the flow Three steps. A `requestAssetUpload` mutation authorizes the intent, records a **pending** asset row, and returns a signed URL scoped to one storage key, one declared content type and a short expiry. The client sends the bytes directly to storage. An `attachAsset` mutation then binds the stored object to the domain record — and this is where verification happens: read the stored object's real size and type, do not trust what the client declared at step one, and make the attach idempotent so a retried call is harmless. ## The authorization point moves This is the part interviews probe. The signed URL is a **bearer credential**: whoever holds it can write those bytes. So the decision "may this caller upload here" is made by the minting mutation, not by storage, and the signature has to be narrow — one key, one method, a bounded content type, minutes not hours of validity. A URL scoped to a prefix rather than a key lets a caller write objects it was never authorized to write, and a long expiry turns a leaked log line into a standing write grant. ## The lifecycle you have just bought Two phases mean two failure states. An object uploaded whose attach mutation never arrives is an **orphan**: bytes with no record. A pending record whose upload never happens is the mirror image. Neither exists with a single mutation. Close them deliberately — pending rows expire, a sweep deletes stored objects with no attachment after a grace window, and the attach path tolerates being called twice. **An ordering assumption that broke.** In one museum digitisation pipeline the storage service's own object-created notification and the client's attach mutation raced. The notification handler, running in a different service from the one that minted the ticket, arrived first, found no pending row — the minting transaction had not yet committed and replicated — concluded the object was an orphan, and the nightly sweep deleted 63 master scans that had uploaded perfectly. The code was right about every individual step and wrong about the order they could occur in. The fix was not faster replication: it was to make the sweep act only on objects older than the grace window, and to make the notification path create-or-update rather than assume the record already existed. ## How to answer Do not present one route as modern and the other as legacy. Say what each buys: multipart buys atomicity and one authorization decision, signed URLs buy scale, resumability and a graph that never holds bytes. Then say where you draw the line — a size threshold, a rule that anything a user can retry belongs directly in storage — and what you built to close the orphan window. An interviewer wants the second half as much as the first, because the two-phase lifecycle is where teams actually get hurt.

  • Where is the upload authorized in a signed-URL flow, and what does that imply about the URL?
    At the mutation that mints it. Storage validates the signature, not the caller's identity, so the URL is a bearer credential: anyone holding it can write those bytes until it expires. That forces the signature to be narrow — one key rather than a prefix, one method, a bounded content type, minutes of validity — and it means the URL deserves the handling of a secret in logs, traces and error reports.
  • How do you stop orphaned objects accumulating in storage?
    Make the pending record the source of truth and give it a deadline. The minting mutation writes a pending row; the attach mutation marks it attached and is idempotent; a sweep removes stored objects with no attachment older than a grace window generous enough to cover a slow upload. The grace window is the load-bearing part — a sweep that acts immediately will delete uploads that are simply still in flight.
  • If the file is small, does the multipart route still have an advantage worth keeping?
    Yes, and it is atomicity. One mutation means one authorization decision, one validation of file and metadata together, and one transaction, so there is no window in which the object exists without the record that gives it meaning. There is no second service, no pending state and no sweep. For small assets that only make sense alongside their metadata, that simplicity is the feature.
  • What verification still belongs on the server after a direct upload?
    Everything the client asserted. The declared content type and size were claims made before the bytes existed, so the attach path should read the stored object's actual size and sniff its real type, reject a mismatch, and generate its own storage key rather than reuse a client-supplied name. Anything that must scan content runs out of band, which means the asset needs a state in which it is stored but not yet usable.

Handing the archivist a parcel with the paperwork is fine for a photograph; for a crate you send the crate to the loading dock and hand over the docket.

saying these in an interview costs you the question

  • Calls signed uploads strictly better than multipart
  • Signs a whole storage prefix instead of one key
  • Treats the signed URL as harmless to log
  • Trusts the content type declared when minting
  • Ignores objects uploaded but never attached
  • Assumes every hop will forward a non-JSON body

context