Node.js AI Image Generation: Decode Errors in Compatible Object Storage
Short answer: S3-compatible object storage is a good home for AI-generated images when a worker uploads the PNG buffer as binary, verifies the stored metadata before its database commit, and serves the result with a presigned link instead of a permanent public URL. A malformed base64 decode error i

Short answer: S3-compatible object storage is a good home for AI-generated images when a worker uploads the PNG buffer as binary, verifies the stored metadata before its database commit, and serves the result with a presigned link instead of a permanent public URL.
A malformed base64 decode error is usually an ownership mistake at an interface. A PNG already has bytes; turning those bytes into text for a queue, then decoding them again before upload, creates room for a data-URL prefix, missing padding, or a second decode. Decode at the documented generation boundary once, validate the bytes, and keep the rest of the path binary.
The signal is simple: the application accepts a generation, yet the delivered object cannot later be read or no longer has the expected size and image/png type. A successful generation request is not the success event. For the storage SLO, success means a durable object whose metadata has been checked, followed by a database record that points to it.
That distinction matters during capacity planning. Base64 expands a payload and encourages a team to carry large strings through components that should only carry job identifiers. Make the object key deterministic, for example userId/jobId.png, put that key and a job ID in the queue, and let the worker retain ownership of the buffer. The resulting sequence is easy to reason about: write bytes, inspect bytes, then publish the database state.
This is also a server-side workflow. The bucket model has CORS rules, but there is no independent route to configure them, so browser-direct upload cannot be treated as self-service. Keep the upload worker on the server when that policy must be predictable.
Use PUT, then the metadata endpoint, then the database transaction. The runnable Go worker below shows the wire behavior required for the same Node.js pipeline: it reads the generated PNG buffer, checks its signature, sends a binary object write with an explicit method, retries only rate limiting with exponential backoff while honoring Retry-After, and checks the response status before any application state changes. INFRAI_BASE_URL is set to the service API base URL by deployment configuration; the three routes in the program are the supported storage routes.
package main import ( "bytes" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func send ( method, endpoint, apiKey, idempotencyKey string, body [] byte ) ( * http. Response, error ) { for attempt:= 0; attempt < 4; attempt ++ { req, err:= http. NewRequest ( method, endpoint, bytes.
NewReader ( body )) if err!= nil { return nil, err } req. Header. Set ( "Authorization", "Bearer " + apiKey ) if method == http.
MethodPut { req. Header. Set ( "Content-Type", "image/png" ) req.
Header. Set ( "Idempotency-Key", idempotencyKey ) } resp, err:= http. DefaultClient.
Do ( req ) if err!= nil || resp. StatusCode!= http. StatusTooManyRequests { return resp, err } io.
Copy ( io. Discard, resp. Body ) resp.
Body. Close () delay:= time. Duration ( 1 << attempt ) * time.
Second if seconds, err:= strconv. Atoi ( resp. Header.
Get ( "Retry-After" )); err == nil && seconds > 0 { delay = time. Duration ( seconds ) * time. Second } time.
Sleep ( delay ) } return nil, fmt. Errorf ( "rate limit persisted after bounded retries" ) } func require2xx ( resp * http. Response, action string ) error { defer resp.
Body. Close () if resp. StatusCode >= 200 && resp.
StatusCode < 300 { return nil } body, _:= io. ReadAll ( resp. Body ) return fmt.
Errorf ( "%s rejected: status=%d body=%s", action, resp. StatusCode, strings. TrimSpace ( string ( body ))) } func main () { apiKey:= os.
Getenv ( "INFRAI_API_KEY" ) baseURL:= os. Getenv ( "INFRAI_BASE_URL" ) bucket, userID, jobID:= os. Getenv ( "STORAGE_BUCKET" ), os.
Getenv ( "USER_ID" ), os. Getenv ( "JOB_ID" ) png, err:= os. ReadFile ( os.
Getenv ( "PNG_FILE" )) if err!= nil || apiKey == "" || baseURL == "" || bucket == "" || userID == "" || jobID == "" { panic ( "PNG_FILE, INFRAI_API_KEY, INFRAI_BASE_URL, STORAGE_BUCKET, USER_ID, and JOB_ID are required" ) } if len ( png ) < 8 ||! bytes. Equal ( png [: 8 ], [] byte { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a }) { panic ( "PNG_FILE is not a PNG buffer" ) } key:= url. PathEscape ( userID ) + "/" + url.
PathEscape ( jobID ) + ".png" putURL:= baseURL + "/storage/object/put/" + url. PathEscape ( bucket ) + "/" + key put, err:= send ( http. MethodPut, putURL, apiKey, "image:" + userID + ":" + jobID, png ) if err!= nil { panic ( err ) } if err:= require2xx ( put, "upload" ); err!= nil { panic ( err ) } headURL:= baseURL + "/storage/object/head/" + url.
PathEscape ( bucket ) + "/" + key head, err:= send ( http. MethodGet, headURL, apiKey, "", nil ) if err!= nil { panic ( err ) } if err:= require2xx ( head, "metadata check" ); err!= nil { panic ( err ) } fmt. Printf ( "verified %s; the database commit may proceed \n ", key ) } Enter fullscreen mode Exit fullscreen mode Compare the returned content length and content type to the original buffer and image/png before the commit.
A rejected response body should be surfaced to the job operator rather than silently discarded. I'm not sure which response representation an individual image generator uses, so confirm that provider's documented response shape at the one decode boundary; the binary-storage rule does not change.
The verification step deserves more attention than it usually gets in an image pipeline review. A PUT proves that the write request received a response; it does not make the database row truthful by itself. Read the metadata with the supported head route, compare the observed byte count to len(png), compare the stored type to the type the worker sent, and only then make the image visible to the rest of the application.
If that comparison fails, retain the job state for investigation and do not publish a record that points users at an object whose representation has not been confirmed. This keeps the failure boundary narrow: the generator has produced bytes, the storage worker has either verified those bytes or has not, and the database has a state that reflects that fact. It also gives an on-call engineer a useful place to measure the gap between generated jobs, verified objects, and committed records, instead of treating every accepted generation as successful delivery.
There is no object versioning, object lock, or If-Match conditional write protection. If a user can regenerate userId/jobId.png, coordinate the overwrite in a database row or queue: claim one active writer, write the object, verify it, and advance the record last. An object write by itself cannot offer strict concurrency control.
For a reversible product action, assign every generation a new immutable key and switch the database pointer transactionally after metadata verification. Rollback then means restoring the prior pointer. An overwritten object cannot be recovered as a version, so it is not a rollback plan.
Lifecycle expiration can remove retired objects, but its shortest interval is one day. It won't clean hourly scratch data, multipart fragments need their own cleanup process, and metadata is not server-searchable: listing filters by prefix only. Maintain a database index for retention, reporting, and incident work.
This is a buy-versus-build choice, not a naming exercise. The image path needs private binary storage, deterministic keys, and temporary delivery; the remaining questions are who owns the data plane, which access controls are already familiar, and how many control planes the platform team must carry.
Infrai is worth considering when one key and one bill across backend capabilities reduce credential sprawl and month-end invoice reconciliation. That is an access-management advantage, not a substitute for storage operations.
The catch is fit. Stay with AWS S3 for deep AWS-native controls, Cloudflare R2 for an edge-centered application, and MinIO when data-plane ownership is worth the on-call load. This private-storage workflow is not suitable for static-site hosting, permanent public image links, finance-grade immutable retention, or browser uploads that require independently managed CORS. An authorized reader can receive a temporary presigned link through the supported presign route; treat it as access, not as a public asset identity.