DEV Community

EmersonPrice3718
EmersonPrice3718

Posted on

Why AI PNG Uploads Fail: Node.js Buffer Boundaries in Compatible Object Storage

Short answer: keep the generated image as bytes from the model boundary to the object-storage request, decode base64 exactly once only when the producer actually returned base64, and make retention state explicit before a delete job is allowed to run.

For a gaming service that keeps per-tenant image backups and restores a selected snapshot, the important design is not the upload call. It is the boundary around that call. A PNG must remain a PNG byte stream, a retry must address the same logical object, and deletion must be a deliberate state transition rather than a side effect of a failed restore.

Keep the object private.

I start with invariants. The stored object has the expected signature, media type, and byte length. The database names the snapshot only after verification. A tenant can ask for a restore while another snapshot is being deleted without turning the result into a race. Those checks are more useful than a successful-looking 200 response.

Decision record: define the storage boundaries

An image generator can return raw binary data, a base64 string, or a data URL containing a base64 string. These are different representations. A Node.js Buffer should go directly to the request body. A base64 value should be decoded once. A data URL needs its media-type prefix removed before that one decode. Calling toString() on binary data and later decoding the resulting text is a common way to turn valid bytes into a malformed payload.

The adapter should normalize all three cases into one internal value: bytes plus a declared media type. Reject an empty value, an unexpected PNG signature, and a media type that does not match the job before making a storage request. A 400-style “malformed base64” message is usually a representation error, not evidence that the object store damaged the image.

The object key is also an invariant. Include the tenant, generation job, and snapshot identity in it, for example tenant-17/snapshot-0042.png. Reuse that key on retries. Do not create a fresh random key inside retry code, because a timeout after a successful write would then leave an untracked duplicate behind. If two valid generations can intentionally target one key, serialize that operation in the application or queue; object storage alone should not be treated as the transaction coordinator.

After the write, read the object's metadata and compare its length and content type with the normalized input. Persist the database row only after that check. The verification is not a replacement for an end-to-end checksum, but it catches a truncated body and a missing media type close to the cause, where the failure is still diagnosable.

How should Node.js AI image generation handle malformed base64 before PNG storage?

The critical path is short. Keep it that way.

  1. Identify whether the provider returned bytes, base64, or a data URL.
  2. Normalize the result once and validate the PNG signature.
  3. Write the bytes to a deterministic tenant and snapshot key.
  4. Verify length and media type before committing the snapshot row.
  5. Return a time-limited read URL only after the snapshot is marked available.

The following Python example shows the protocol boundary without coupling the design to a particular SDK. The same ordering applies in a Node.js service: its Buffer is the png_bytes value, and its HTTP client must send those bytes as the request body rather than JSON text.

import base64
import binascii
import json
import urllib.parse
import urllib.request

PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"


def normalize_png(value, representation):
    if representation == "bytes":
        png_bytes = value
    elif representation == "base64":
        try:
            png_bytes = base64.b64decode(value, validate=True)
        except (binascii.Error, ValueError) as exc:
            raise ValueError("image payload is not valid base64") from exc
    elif representation == "data-url":
        prefix, separator, payload = value.partition(",")
        if not separator or ";base64" not in prefix:
            raise ValueError("image data URL is not base64")
        try:
            png_bytes = base64.b64decode(payload, validate=True)
        except (binascii.Error, ValueError) as exc:
            raise ValueError("image data URL has malformed base64") from exc
    else:
        raise ValueError("unknown image representation")

    if not png_bytes.startswith(PNG_SIGNATURE):
        raise ValueError("normalized payload is not a PNG")
    return png_bytes


def put_and_verify(endpoint, token, bucket, object_key, png_bytes):
    path = "/objects/" + urllib.parse.quote(bucket, safe="")
    path += "/" + urllib.parse.quote(object_key, safe="/")
    request = urllib.request.Request(
        endpoint + path,
        data=png_bytes,
        headers={
            "Authorization": "Bearer " + token,
            "Content-Type": "image/png",
        },
        method="PUT",
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        if response.status not in (200, 201, 204):
            raise RuntimeError("object write was not accepted")

    # The metadata read must target the same deterministic key.
    check = urllib.request.Request(
        endpoint + path,
        headers={"Authorization": "Bearer " + token},
        method="HEAD",
    )
    with urllib.request.urlopen(check, timeout=30) as response:
        length = int(response.headers["Content-Length"])
        media_type = response.headers.get_content_type()
    if length != len(png_bytes) or media_type != "image/png":
        raise RuntimeError("stored object failed verification")

    return {"key": object_key, "length": length, "media_type": media_type}
Enter fullscreen mode Exit fullscreen mode

The example deliberately does not retry every exception. Retrying a request after an unknown outcome is safe only when the key and write semantics are understood, and a retry policy should distinguish rate limiting, a transport timeout, and a validation failure. I would log the tenant, snapshot ID, byte length, and a request correlation ID, but never the image bytes or an access token. Your mileage may vary on the exact retry budget; it should follow the queue's delivery contract and the storage service's documented behavior.

Retention and deletion are part of restore correctness

Generated images become backups the moment a restore workflow can select them. That changes the deletion question. A cleanup process must know whether a snapshot is pending, available, restore_requested, restored, or delete_eligible; it should never infer eligibility from object age alone.

For each tenant, keep the retention decision in durable metadata: snapshot creation time, expiration time, generation status, and the policy version that produced the deadline. A lifecycle rule can remove old objects automatically, but the application still needs a way to stop a restore from selecting an object that policy has marked for deletion. The database and object store are separate systems, so deletion needs an observable transition: mark the row, enqueue the object deletion, record the result, and reconcile orphaned rows and orphaned objects.

The difficult case is a restore racing with cleanup. Claim the snapshot before reading it, or use a lease whose expiry is longer than the expected restore. Cleanup should skip claimed snapshots and should be safe to run again. If a delete request succeeds but the database update times out, reconciliation must converge on the same state; otherwise a later retry can either resurrect a supposedly deleted image or report a snapshot that no longer exists. That needs more than a timestamp check: the worker should load the current row, verify that the policy version still permits deletion, record an attempt ID, issue the object delete, and then compare the resulting state with the row it originally claimed. A restore request arriving between those steps should either inherit the claim or receive a clear “snapshot is being retained” decision. Silent last-writer-wins behavior is the failure mode to design out.

Which storage strategy fits this backup workflow?

The answer depends on the guarantee the team needs, not on whether an upload example is convenient.

Strategy Good fit Boundary or cost to accept
S3-compatible object storage with application metadata Private tenant snapshots, large PNGs, and time-based retention The database must coordinate restore claims, deletion, and orphan reconciliation
Object storage with native lifecycle rules Predictable age-based cleanup with low application traffic Lifecycle expiration is asynchronous; it is not a restore lock or an audit record
Database binary or base64 storage Very small artifacts that must commit atomically with relational state Payload size, encoding overhead, and database backup growth become the bottleneck
Content-addressed immutable storage Deduplication and strong snapshot identity matter more than simple expiry Deletion becomes a garbage-collection problem and requires a clear legal-retention policy

The catch is that the first option is not suitable when the product needs native immutable retention, cross-region disaster recovery, server-side image search, or a provider-specific conditional-write guarantee that the abstraction does not expose. Choose a storage system with those controls, or add a coordination layer that can enforce them. Stick with database storage when the files are genuinely small and atomic row-plus-payload commits are the dominant requirement.

Do not make price the decision rule. The operational questions are sharper: who owns the retention policy, how is a failed delete detected, how is a selected snapshot protected during restore, and which evidence proves that the bytes read are the bytes written? Those answers determine the architecture.

Rejected option: decode at every boundary

Repeatedly converting an image between bytes, base64, JSON, and bytes creates more failure points without improving durability. It also makes logs tempting to inspect, which can expose user-generated content. Normalize once at the edge, keep binary data binary, and pass a small metadata record between the image job, storage worker, and restore coordinator.

A public URL as the database's permanent representation is another poor fit for private tenant backups. Authorization belongs in the application workflow; a short-lived read URL can be produced after the snapshot claim is checked. For browser upload progress, the client can observe upload events, but progress is not proof of durable storage. The server-side verification and state transition still decide whether a snapshot is available.

This is the decision I would record: bytes are validated once, object identity is deterministic, retention is explicit, and deletion is reconciled. The upload endpoint is only one boundary in that system.

References

Top comments (0)