DEV Community

LiraelVex6403
LiraelVex6403

Posted on

Vue Frontend File Upload Runbook: Direct Presigned URLs, Axios Progress, Node Control

Use a short-lived upload capability, record an upload intent before issuing it, and make the browser report completion only after a separate finalize call succeeds. That is the operationally defensible way to let a Vue frontend send a file directly to private object storage while a Node backend retains authorization and application state.

The deciding constraint is not the progress bar. It is the boundary between bytes accepted by an object store and a file that the application is prepared to expose to a user. A browser can lose its final response, retry a request, or leave a tab halfway through an upload; a system that has no explicit state for those cases will eventually turn a harmless network interruption into an incorrect attachment record or an object nobody can account for.

Keep the data plane out of the API service. Keep the authority plane in it.

What should a Vue frontend, direct file upload, presigned URL, private object storage, and Node backend each own?

Treat a presigned URL as a narrow bearer capability, not as a login token. The Node backend authenticates the caller, decides whether that caller may create an object, allocates a server-controlled key, records an initiated upload, and returns a URL whose method, key, expiry, and required headers match that intent. The browser then sends the bytes directly to storage. It cannot choose another user's prefix merely by changing a form field.

The Vue component owns local interaction: selection, cancellation, display state, and the request lifecycle. Axios can update a progress value from its upload progress callback, but that value only represents bytes observed by the client transport. It is useful feedback, not proof of durability. Keep the UI in an uploading state until the browser has received a successful response from storage and the backend has accepted a finalization request. A short message such as “finishing upload” is more honest than a 100% indicator that has already implied availability.

This split changes capacity planning in a helpful way. Upload bytes do not consume API ingress, application memory, or long-lived Node request handlers, while authorization requests and finalization writes remain small control-plane operations with ordinary latency and availability objectives. Measure them separately: ticket latency, storage PUT completion, finalization latency, abandoned intents, and the age of every nonterminal upload state. A single “upload succeeded” counter hides the parts that page people later.

The control-plane contract should be idempotent. Have the client create one random upload token per user action and reuse it if it must request another capability; have the backend map that token and user identity to the same pending upload record. The object key stays server-generated. If a user retries after the URL expires, the backend can issue a fresh capability for the same intended object instead of silently creating another logical file.

Concern Browser Backend control plane Private object storage
Authorization Presents the user's session Authenticates and applies policy Accepts only the signed capability
Object identity Holds the upload token Allocates and persists the key Stores bytes at that key
Completion Shows transport and finalization states Verifies and records readiness Acknowledges the write

The failure signal is a false green progress bar

There are at least three completion events: the browser has handed bytes to its networking stack, the storage service has acknowledged the write, and the application has marked the object ready after checking the intended upload record. Collapsing them into one state is the common failure mode.

An Axios progress callback may have no computable total, and even a 100% value does not prove that the subsequent response and application finalization occurred. In Vue, represent this as a small state machine rather than a boolean: selected, authorizing, uploading, finalizing, ready, cancelled, and failed. The meter belongs only in uploading; finalizing has no percentage to invent.

Short state names help.

The browser also needs CORS permission for the direct storage request. The configured origin, upload method, and headers used by the signed request have to agree. Test this in a real browser before calling the endpoint done, because a browser-enforced CORS rejection can leave the API logs perfectly quiet. Do not loosen the rule to every origin and every header to get past a test; that trades a visible integration problem for an unnecessary write surface.

For private objects, access is a separate decision from upload. A successful PUT should not make the bucket generally readable. When an application later grants a download capability, use a response policy appropriate to the object and audience. Cache-Control: private prevents shared caches from storing a response, while no-store asks caches not to store it at all; the semantics and directives are defined in the HTTP specification and summarized by MDN. Pick deliberately, because cache behavior is part of the data-access policy, not a cosmetic header.

A safe Node control-plane shape

The following Go sketch represents the backend rules a Node implementation should preserve: authenticate before minting a capability, persist the intent before returning it, bind replay to an idempotency key, and transition to ready only through an explicit finalize operation. The actual signing mechanism depends on the storage protocol, so the interface keeps that provider-specific detail out of the application contract.

package uploads

import (
    "context"
    "time"
)

type Upload struct {
    ID        string
    UserID    string
    ObjectKey string
    State     string
}

type Store interface {
    Claim(ctx context.Context, userID, clientToken, contentType string, size int64) (Upload, error)
    MarkReady(ctx context.Context, userID, clientToken string) error
}

type Signer interface {
    PutURL(ctx context.Context, objectKey, contentType string, expiresAt time.Time) (string, error)
}

func IssueUpload(ctx context.Context, db Store, signer Signer, userID, clientToken, contentType string, size int64) (string, Upload, error) {
    // Claim returns the existing pending record for the same user and token.
    upload, err := db.Claim(ctx, userID, clientToken, contentType, size)
    if err != nil {
        return "", Upload{}, err
    }

    url, err := signer.PutURL(ctx, upload.ObjectKey, contentType, time.Now().Add(5*time.Minute))
    if err != nil {
        return "", Upload{}, err
    }
    return url, upload, nil
}

func FinalizeUpload(ctx context.Context, db Store, userID, clientToken string) error {
    // The implementation verifies the intended object before marking it ready.
    return db.MarkReady(ctx, userID, clientToken)
}
Enter fullscreen mode Exit fullscreen mode

The details that deserve review are not framework details. Reject a missing or oversized file before signing, allow only content types the product is prepared to process, rate-limit capability issuance, and ensure finalization verifies the expected object identity and metadata. If malware scanning, content inspection, or transformation is mandatory before any byte reaches durable storage, direct browser upload may be the wrong architecture; route the content through a controlled processing path or quarantine it until the required checks complete.

There is a real trade-off here. Direct-to-storage upload is not suitable when files are tiny, upload volume is low, and the server must inspect every stream anyway. In that situation, proxying through the backend can be easier to operate because there is one request, one audit trail, and no reconciliation loop. Stick with the simpler path until API bandwidth, tail latency, or connection pressure gives you a measured reason to add the extra states.

How do you verify the path and roll it back?

Verification compares independent records, not two views of the same database. Regularly reconcile the set of ready upload rows, the set of object keys in private storage, and the set of objects referenced by the product. Make the reconciliation job produce counts for all three sets plus their differences, retain a bounded sample of keys for diagnosis, and tag the output with the storage namespace and application release so an operator can distinguish a policy error from a deployment change. An object with no completed record is an abandoned candidate subject to a retention rule; a ready record with no object is an availability defect that needs investigation before a user encounters it. Also track objects whose observed content type or size differs from the recorded intent, because a mismatch is evidence that the signing boundary or finalization check is too broad. Set an SLO for how long an upload may remain in finalizing, then alert on age and count rather than waiting for a support ticket; the useful alert identifies a growing backlog, while a one-off cancellation is expected behavior.

Exercise the browser flow with a throttled connection, a cancellation, an expired capability, a repeated authorization request using the same token, and a completed PUT followed by a failed or repeated finalization call. The expected outcome matters more than the HTTP client: repeated authorization resolves to the same pending upload, finalization is safe to repeat, and no object becomes visible merely because a percentage meter reached its end.

Rollback should remove the ability to issue new upload capabilities while leaving existing private objects under their normal read policy. A short expiration limits the time outstanding write URLs remain valid; document that interval in the runbook, observe its effect on long uploads, and choose it from the largest supported upload and expected network conditions rather than copying a number from a sample. Your mileage may vary with file size and user connectivity.

The design earns its complexity only when it improves a concrete operational limit. Otherwise, don't build a second workflow merely to make a diagram look modern.

References

Top comments (0)