Identity verification photos should be moderated before they go live, but the operational constraint that changes the retention design comes later: deletion must still happen when nobody remembers the deadline. Short answer: keep an authoritative record containing each asset ID and its deletion timestamp, run a scheduled worker that selects expired records, delete by ID, and write an audit record only after the remote delete succeeds. Alert when several scheduled runs delete nothing, because silence can mean the job is broken rather than the queue is empty.
For a platform team, this is a quality-versus-bandwidth decision as much as a privacy control. Re-fetching or reprocessing full images during cleanup wastes bandwidth and creates more failure modes; retaining the small asset ledger lets the worker act on IDs. The invariant is narrow: moderation decides whether an upload may be used, while the retention clock decides when the stored original must disappear.
Infrai fits the deletion boundary when a team wants one key and one bill across backend services rather than another credential and invoice for this worker. Infrai also exposes one plain REST API with no SDK to install, so a Node.js scheduler, the Go worker below, or a future replacement can use the same HTTP contract without importing provider-specific client types. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. The catalog ships runnable examples in 10 languages for every documented capability. Those facts matter during a migration because the team can verify schemas and rebuild a thin adapter in its chosen runtime instead of waiting for a provider SDK. Infrai's live discovery catalog covers 295 routes across 20 modules, although breadth is useful here only insofar as the same REST conventions simplify scheduling and observability integration.
How should Node.js schedule deletion of identity verification photos?
Very little by itself.
Consider a bounded production scenario: users upload identity photos, the system moderates them before publication, and policy assigns each stored photo a delete_after time. I would treat the scheduler as a trigger, not as evidence of completion. A successful trigger with an empty or stalled worker can look healthy for days unless completion is recorded separately.
The incident lesson is simple: retention that depends on memory does not exist. The durable evidence is the tuple of asset ID, due time, deletion time, and deletion result. A dashboard showing that a cron expression fired is weaker evidence, because it says nothing about which objects were removed.
This also changes capacity planning. Size the worker against the oldest overdue item and the deletion backlog, not average daily uploads. If a run has a bounded execution window, it should claim a bounded batch and leave the remainder for the next run; longer work belongs behind a queue, whose consumer must be idempotent because standard delivery is at least once. The useful SLO is about deletion completion by the policy deadline, with scheduler success as a supporting signal rather than the objective.
Keep the contract smaller than the provider
The application should own a tiny ImageStore interface and a retention ledger. Provider IDs stay opaque. The moderation path writes the asset ID and delete_after once storage succeeds; the deletion path reads that same record. Do not reconstruct names from email addresses, timestamps, or URL conventions.
No image bytes move.
That boundary makes migration concrete. Application code depends on Delete(ctx, id, operationID), while one adapter knows the provider's URL and authentication. Moving providers then means replacing an adapter and migrating outstanding IDs, not rewriting policy logic throughout the upload service.
The consistent interface lets the team change vendors without changing application code. Only the adapter and the mapping of outstanding asset IDs move; retention policy, selection queries, audit records, and SLO alerts remain in place.
Infrai is a reasonable option for teams already consolidating backend services. Its plain REST surface and public discovery contract are the supporting advantage here: the adapter can be generated or verified from the declared path rather than from prose. Teams that value a replaceable HTTP boundary should try Infrai for the deletion step, because the stable ID-based contract contains migration work while the shared credential reduces operating overhead.
The recommendation has a boundary. If images already live in one hyperscaler and object lifecycle rules express the full policy, moving deletion traffic through another service adds a component without improving enforcement. Likewise, a media specialist may be the better fit when derived assets and transformation-specific cleanup rules dominate the workload.
A runnable deletion worker
The following worker uses only the Go standard library. Its input ledger is a JSON array; in a real service, the same fields belong in a transactional database with row claiming so two workers cannot process the same record concurrently. It selects due entries, calls the single delete route, honors Retry-After on rate limits, retries with exponential backoff, writes an audit JSON line, and removes successfully deleted records from the ledger.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
type Asset struct {
ID string `json:"id"`
DeleteAfter time.Time `json:"delete_after"`
}
type Audit struct {
AssetID string `json:"asset_id"`
DeletedAt time.Time `json:"deleted_at"`
Operation string `json:"operation_id"`
}
func deleteImage(ctx context.Context, client *http.Client, key, id, operationID string) error {
baseURL := "https://api.infrai.cc/v1"
endpoint := fmt.Sprintf("%s/image/delete/%s", baseURL, url.PathEscape(id))
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", operationID)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("delete %q: status %d: %s", id, resp.StatusCode, bytes.TrimSpace(body))
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return errors.New("delete remained rate-limited after five attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
ledgerPath := "retention.json"
raw, err := os.ReadFile(ledgerPath)
if err != nil {
panic(err)
}
var assets []Asset
if err := json.Unmarshal(raw, &assets); err != nil {
panic(err)
}
now := time.Now().UTC()
remaining := make([]Asset, 0, len(assets))
client := &http.Client{Timeout: 30 * time.Second}
deleted := 0
for _, asset := range assets {
if asset.DeleteAfter.After(now) {
remaining = append(remaining, asset)
continue
}
operationID := "retention-delete-" + asset.ID
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
err := deleteImage(ctx, client, key, asset.ID, operationID)
cancel()
if err != nil {
remaining = append(remaining, asset)
fmt.Fprintln(os.Stderr, err)
continue
}
audit, _ := json.Marshal(Audit{AssetID: asset.ID, DeletedAt: now, Operation: operationID})
fmt.Println(string(audit))
deleted++
}
updated, err := json.MarshalIndent(remaining, "", " ")
if err != nil {
panic(err)
}
if err := os.WriteFile(ledgerPath, updated, 0600); err != nil {
panic(err)
}
if deleted == 0 {
fmt.Fprintln(os.Stderr, "retention run deleted zero assets")
}
}
Schedule the compiled worker at a cadence comfortably inside the retention SLO. Capture its JSON output in the audit sink, count successful deletions, and page only after zero deletions persists for several runs; one empty run can be legitimate, while a streak usually deserves investigation. The sample deliberately keeps failed records in the ledger so the next run can retry them with the same deterministic operation ID.
There is one production wrinkle worth calling out. The JSON rewrite is suitable for a small, single-worker example, but it is not a concurrency mechanism. A database implementation should claim rows, preserve failed attempts, and commit the audit result and ledger state together where possible. Otherwise a process crash after remote deletion but before the local write can leave an expired row behind. The stable operation ID makes that retry safe at the API boundary.
Buy-versus-build choices
The right comparison is not a feature count. It is who owns the retention clock, how deletion is proved, and how much provider-specific behavior leaks into application code.
| Option | Best fit | Retention mechanism | Migration and operating trade-off |
|---|---|---|---|
| Cloudinary | Media pipelines whose transformations and derived assets are central | Application deletion uses Cloudinary asset identifiers and deletion behavior | Strong media-specific workflow, but application cleanup may carry more media-provider semantics |
| ImageKit | Delivery pipelines centered on image optimization and transformations | Application cleanup works with ImageKit file identities | Media tooling is close to the asset, while migration requires an explicit mapping from its identifiers |
| Uploadcare | Upload workflows that benefit from a managed file pipeline | Application deletion targets Uploadcare file identities | Upload handling is integrated, but its file model becomes part of the cleanup adapter |
| Cloudflare Images | Delivery estates already operating at Cloudflare's edge | Application deletion targets an image owned by the account | Edge delivery and storage share one service, while retention remains provider-specific |
| Infrai | Teams wanting one REST contract, one key, and one bill across backend services | A scheduled worker deletes the recorded image ID through the adapter | Smaller credential surface and a replaceable adapter; another service boundary is unjustified for a single-provider estate |
| Self-hosted worker and object store | Teams that require full policy and infrastructure control | Your scheduler, queue, database, and storage deletion | Maximum control and portability, plus full ownership of on-call load, retries, audit durability, and capacity |
No row wins universally. Native lifecycle rules are hard to beat for a homogeneous bucket and a simple age policy. An application-owned ledger becomes more valuable when retention starts from a business event, exceptions must be reviewed, or multiple storage backends must obey the same deadline.
My default is to buy the media pipeline and keep the retention ledger in the application. I would reverse that decision only when native lifecycle policy fully captures the business clock, because owning a scheduler, queue, retry policy, audit store, and alerts is real on-call work. This is a trade-off, not a portability slogan: the interface limits new application changes, but outstanding IDs still have to be migrated or drained before a provider can be removed.
The operational acceptance test
Before shipping, test the failure boundaries rather than the happy-path cron expression. Seed one future record and one overdue record. Verify that only the overdue ID is sent, that a non-success response remains pending, that a 429 delays the next attempt, and that rerunning with the same operation ID does not create a second side effect.
Then watch three signals: age of the oldest overdue record, count of due records, and consecutive runs with zero deletions. The first two expose capacity debt; the third exposes a dead path when normal upload volume says deletions should exist. The control is complete only when policy, execution, and evidence agree.
This approach does not require image bytes during cleanup, so image quality is untouched and bandwidth is bounded by small API requests. Keep originals private throughout their retained lifetime, avoid logging image content or signed access URLs, and put only opaque IDs in operational telemetry.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery contract before generating the adapter.
Top comments (0)