Short answer: accept the upload through a controlled Next.js API route, keep the source image private, create a fixed set of Sharp derivatives in a worker or bounded server-side step, and give an authenticated customer a short-lived signed URL only after the derivative is marked ready.
That decision puts access control ahead of delivery convenience. A thumbnail is still customer data, even when it looks harmless. The API route should therefore coordinate identity, validation, object keys, derivative creation, and readiness; it should not turn every image request into an unbounded resize job.
The report request is the security boundary
Treat the workflow as a small state machine rather than a single upload callback:
- Authenticate the customer and authorize the report or image ownership.
- Accept the upload under an application-defined size and media policy.
- Write the original to a private object key.
- Create the configured thumbnail variants with Sharp.
- Write those variants to deterministic private keys.
- Mark the asset ready, then mint a signed read URL for the authorized request.
The important boundary is step six. A successful original upload does not prove that the report can be served; it proves only that one object exists. A record such as processing, ready, or failed gives the report service something explicit to check instead of guessing from a missing thumbnail.
Keep the variant list finite. For example, thumb and preview are product decisions, not dimensions supplied by an arbitrary browser request. Deterministic keys such as customers/{customer_id}/reports/{report_id}/thumb.webp make retries address the same object. They also make cleanup enumerable, while a database record can retain the authorization relationship and readiness state.
Here is the orchestration contract in Python. The storage adapter and resize function stand in for the corresponding Node.js implementations; the point is the invariant, not a pretend provider-specific request body.
from collections.abc import Callable
from dataclasses import dataclass
@dataclass(frozen=True)
class Variant:
name: str
width: int
height: int
VARIANTS = (
Variant("thumb", 96, 96),
Variant("preview", 640, 640),
)
def create_report_images(
customer_id: str,
report_id: str,
source: bytes,
put_private: Callable[[str, bytes, str], None],
resize: Callable[[bytes, int, int], bytes],
) -> list[str]:
"""Write a bounded, repeatable set of private report image objects."""
allowed_types = {"image/jpeg", "image/png", "image/webp"}
media_type = detect_media_type(source)
if media_type not in allowed_types:
raise ValueError("unsupported image type")
prefix = f"customers/{customer_id}/reports/{report_id}"
original_key = f"{prefix}/original"
put_private(original_key, source, media_type)
keys = [original_key]
for variant in VARIANTS:
key = f"{prefix}/{variant.name}.webp"
thumbnail = resize(source, variant.width, variant.height)
put_private(key, thumbnail, "image/webp")
keys.append(key)
return keys
The route must not trust a browser-supplied MIME label as the only validation signal; the adapter represented by detect_media_type should inspect the bytes with the image library used by the application. The exact limits depend on deployment memory, image dimensions, and execution time, so I would load-test representative support-report images before choosing them. Your mileage may vary between a long-running Node.js service and a serverless function.
What should the report API do after a user upload for private thumbnail images?
The architecture decision record should name what must remain true:
| Invariant | Failure boundary | Required response |
|---|---|---|
| Objects are private | A URL is requested without an authorized customer context | Return no object key or permanent URL; authorize before signing |
| Variant names are bounded | A client asks for an arbitrary width | Reject the request or map it to a configured variant |
| Ready means every required object exists | The original write succeeds but Sharp or a derivative write fails | Keep the asset non-ready and retry the job using the same keys |
| Report access follows customer ownership | A valid customer asks for another customer's report ID | Deny before storage access |
| Cleanup is complete | A delete operation is interrupted | Persist deletion progress and retry the remaining keys |
The failure mode people tend to miss is partial success. If the source object is present and the database says ready, a later thumbnail failure becomes a serving bug rather than a visible processing state. Publish readiness last. A queue with at-least-once delivery then becomes manageable: repeated work overwrites the same deterministic keys, while the database transition needs an idempotency rule of its own.
Ship less.
Consider a customer support report with one uploaded screenshot and two display sizes. The upload request may authenticate correctly, write the original, and then lose its execution budget while Sharp is decoding a large image. If the handler has already returned ready, the customer sees a broken preview and the next retry has no authoritative state to consult. If the handler records processing before the work and changes it to ready only after both named objects exist, a retry can inspect the same report, repeat the deterministic writes, and publish the same final state. A failed decode can remain non-ready for investigation or retry; a failed signed read can be observed separately from a failed transformation. That separation matters because these events need different operators and different remedies, even though they all look like “the thumbnail is missing” in a browser. Don't collapse them into one boolean such as uploaded.
Signed URLs are capabilities. They should be created only after authorization, have a lifetime appropriate to the report view, and be kept out of application logs where practical. The customer-facing endpoint can return a signed URL without revealing the storage credentials or making the bucket public. The exact signing request is provider-specific; the application contract should expose only operations such as put_private, sign_read, and delete_private.
There is a small but consequential operational distinction between “not found” and “not ready.” A missing database record may mean an invalid report identifier. A known processing record should produce a deliberate pending response, not an accidental storage lookup. I would put a request identifier and asset state in structured logs, then measure derivative duration, decode failures, signing failures, and retry count. Those signals tell the team whether the bottleneck is authorization, image work, or storage delivery.
Storage governance changes the API contract
The image transformation is portable; the storage contract is where provider coupling appears. A direct integration may expose more native controls, while an abstraction can reduce changes in application code when the backend destination changes. Neither choice supplies the readiness state or customer authorization model for free.
| Approach | Useful when | Trade-off |
|---|---|---|
| Direct object-storage SDK | The team needs provider-native controls and already operates one storage system | Signing, retry behavior, and configuration become provider-specific application concerns |
| S3-compatible interface | Existing infrastructure already standardizes on that protocol | Compatibility does not guarantee identical IAM, lifecycle, or replication semantics |
| Storage gateway with a narrow adapter | The application values one internal contract across backends | The gateway becomes another operational boundary and may expose fewer native features |
| Self-hosted object storage | Data placement and operational ownership are primary requirements | The team owns durability, upgrades, monitoring, and recovery procedures |
The comparison should be made against explicit requirements: private reads, upload size policy, signed access, lifecycle deletion, recovery objectives, replication, audit logging, and regional placement. A familiar API is not evidence that all of those properties match. Check the storage documentation and test the exact behavior that the report workflow depends on.
Where should Sharp run after upload?
The report page should request a named derivative, not ask the read path to resize an arbitrary source image. The authenticated endpoint checks the report relationship, looks up the ready variant, signs a read, and returns it. Sharp belongs in the upload workflow or a background worker, where CPU and memory can be bounded and retried without making a customer wait on image decoding.
For a small, predictable image and a runtime with a known execution budget, synchronous processing can be acceptable. The response should say the upload is complete only after all required writes succeed. For larger images, multiple variants, or bursty support traffic, enqueue the asset and return a processing state; the worker can publish ready after the final write. There is no universal threshold here. I would measure the real workload rather than infer capacity from a thumbnail tutorial.
The rejected option is on-demand resizing in the report request itself. It is valid when the product genuinely needs many dimensions, edge transformation, or content negotiation and the team accepts that service in the request path. It is a poor fit for a fixed two-variant support-report workflow because every miss adds image CPU to a customer read, arbitrary dimensions enlarge the cache surface, and a failed render now competes with authorization and storage delivery.
Choose the simpler synchronous path for a tightly bounded workload. Choose a worker when readiness, retries, and burst control matter more than immediate completion. In either case, the durable design is the same: authorize first, store privately, derive named objects, publish readiness last, and sign only the object the customer is allowed to see.
References
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Google Cloud Storage documentation: https://cloud.google.com/storage/docs
Top comments (0)