DEV Community

CelthyrDusk7341
CelthyrDusk7341

Posted on

Node.js Account Deletion: Verified GDPR Erasure for User Avatar Images

TL;DR: Treat the Express account-deletion handler as an orchestrator, not as proof of erasure: freeze the user's avatar mapping, delete every recorded asset by ID, read each ID back, and close the request only after every read confirms absence. Keep the deleted IDs and verification outcomes in an audit record. If quality and bandwidth are competing concerns elsewhere in the photo pipeline, settle that during upload or OCR; deletion must operate on the authoritative asset ledger, never on whatever compressed preview happens to be convenient.

For a B2B SaaS team, the practical service boundary is narrow. Node.js owns the user lifecycle and durable deletion state; a worker performs bounded external calls; the database remains the source of truth for which original photos, avatar variants, and OCR-derived images belong to the account. A successful DELETE response advances the workflow, but it does not finish it.

This is the decision rule: no complete mapping, no defensible deletion.

How should Node.js delete user images during account deletion?

The failure mode starts before the API call. A profile row may point at the current square avatar while an older original, a mobile crop, or an OCR input is still associated with the same user elsewhere. Deleting the visible avatar proves very little if the user-to-asset mapping is incomplete. The deletion job should therefore take an immutable snapshot of all known asset IDs after new uploads have been blocked, rather than repeatedly querying a relationship that can change underneath it.

Then comes the second trap: treating command acceptance as observed state. Networks fail between request and response, retries race, and providers differ in what a delete acknowledgement means. The safe sequence is deliberately boring: issue deletion by asset ID, follow it with a read of that same ID, classify the observed result, and retain the ID-level evidence. Trust the read.

Stop there.

For capacity planning, use calls rather than users as the unit. With N mapped assets, the happy path requires roughly 2N remote operations: one delete and one verification read per asset. Retries add load, and a verification delay stretches completion time. Set the erasure workflow's SLO around verified completion, then derive worker concurrency from the provider quota and the largest credible account, not from median avatar count. A queue depth that looks harmless in users can be large in assets.

Pick the ownership boundary before the vendor

The vendor choice matters less than retaining a complete local ledger. Still, the operational differences are real, particularly for a small platform team weighing on-call load against lock-in.

Option Interface and ownership model Operational trade-off Best fit
Cloudflare Images Image identifiers sit inside a managed image service Keeps delivery and image lifecycle in Cloudflare's control plane Teams already using Cloudflare for image delivery
ImageKit Media identifiers sit inside an image-management workflow Centers the decision on a dedicated image platform Teams that want image management and delivery together
Uploadcare File identifiers sit inside a managed upload and delivery workflow Moves upload handling into the same vendor decision Teams that want managed ingestion as well as media lifecycle operations
Cloudinary Media asset identifiers sit inside a managed image workflow Reduces image-pipeline machinery the application must own Teams that want transformations and lifecycle operations together
Infrai Image assets are addressed through one REST surface One key and one bill can reduce credential and invoice sprawl; the abstraction also increases dependence on that common API Teams consolidating several backend capabilities behind one control plane

Those are not interchangeable promises. Cloudflare Images, ImageKit, Uploadcare, and Cloudinary are dedicated media choices with different surrounding workflows; Infrai groups media with a broader backend surface. The Infrai discovery catalog reports 295 capabilities across 20 modules and runnable examples in ten languages, which is useful when the deletion worker is one of many integrations, but breadth does not remove the application's obligation to maintain ownership records. For this workflow, only the asset-ID delete and subsequent asset-ID read are material. A team should validate identifier semantics, deletion behavior, account-level access controls, and read-after-delete observations against the current documentation of whichever service it selects, because a familiar upload path does not prove that the erasure path matches this runbook.

The quality-versus-bandwidth decision belongs upstream: preserve whichever original and derived assets the product needs, record every resulting ID against the user, and apply retention policy explicitly. Do not infer ownership later from filenames, URLs, OCR text, or the current profile image.

Implement a bounded, verifiable deletion worker

The example below is Go because the worker is intentionally isolated from Express. Express can create the deletion job and wait for its durable state transition; the worker receives a frozen list of IDs. It uses exactly two media routes, sends an explicit method, retries 429 responses with Retry-After when available, and surfaces response bodies on other failures. No key is embedded in source.

The verification contract in this example is conservative: a read returning 404 confirms absence. Any other status, including 200, leaves the job open for investigation or another bounded attempt.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Client struct {
    httpClient *http.Client
    apiKey     string
    baseURL    string
}

func (c *Client) do(ctx context.Context, method, path string) (*http.Response, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+c.apiKey)

        resp, err := c.httpClient.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return resp, nil
        }
        io.Copy(io.Discard, resp.Body)
        resp.Body.Close()

        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 <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("rate limit persisted after bounded retries")
}

func (c *Client) deleteAndVerify(ctx context.Context, id string) error {
    path := "/image/delete/" + id
    resp, err := c.do(ctx, http.MethodDelete, path)
    if err != nil {
        return fmt.Errorf("delete %s: %w", id, err)
    }
    body, readErr := io.ReadAll(resp.Body)
    resp.Body.Close()
    if readErr != nil {
        return fmt.Errorf("read delete response for %s: %w", id, readErr)
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("delete %s returned %d: %s", id, resp.StatusCode, strings.TrimSpace(string(body)))
    }

    resp, err = c.do(ctx, http.MethodGet, "/image/get/"+id)
    if err != nil {
        return fmt.Errorf("verify %s: %w", id, err)
    }
    body, readErr = io.ReadAll(resp.Body)
    resp.Body.Close()
    if readErr != nil {
        return fmt.Errorf("read verification response for %s: %w", id, readErr)
    }
    if resp.StatusCode == http.StatusNotFound {
        return nil
    }
    return fmt.Errorf("asset %s still unverified; read returned %d: %s", id, resp.StatusCode, strings.TrimSpace(string(body)))
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL is required")
        os.Exit(2)
    }
    ids := os.Args[1:]
    if len(ids) == 0 {
        fmt.Fprintln(os.Stderr, "provide at least one asset id")
        os.Exit(2)
    }

    client := &Client{httpClient: &http.Client{Timeout: 20 * time.Second}, apiKey: key, baseURL: strings.TrimRight(baseURL, "/")}
    for _, id := range ids {
        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
        err := client.deleteAndVerify(ctx, id)
        cancel()
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        fmt.Printf("verified_deleted asset_id=%s\n", id)
    }
}
Enter fullscreen mode Exit fullscreen mode

In production, do not pass IDs on a command line. Read them from the durable job snapshot, and write each transition to an append-only audit record containing the deletion-request ID, asset ID, attempt number, timestamps, HTTP outcome, and final verification state. Avoid storing image content, OCR output, bearer credentials, or response bodies that may contain personal data in that record. The point is evidence, not a second copy of the user's data.

The log line in the sample demonstrates the minimum useful correlation field. A real worker should use structured logging and should never mark the parent account request complete merely because the process exited zero once; completion is a database transition conditioned on every snapshotted asset reaching verified_deleted.

Verification, SLOs, and proof

Define three states clearly: deletion requested, deletion acknowledged, and deletion verified. Only the third satisfies the runbook. For a user with four avatar assets, the evidence set must contain four asset IDs and four successful absence checks; one missing row is an incomplete request, even if the other three finished quickly.

The useful service indicators are verified-completion latency, oldest unverified request age, verification failure rate, and retry volume. Page on a sustained threat to the erasure SLO or on requests approaching the policy deadline, not on every transient 429. A retry is expected control flow. An aging unverified asset is the risk.

One number changes the alert from noise into evidence: the age of the oldest job that has not reached verified completion. Aggregate success rates can stay green while a single large account remains stuck behind one overlooked derivative, so the dashboard needs both the population view and that worst-case clock. This also gives the on-call engineer a concrete starting point: open the frozen asset snapshot, compare it with the per-ID audit rows, and resume only the entries that lack a confirmed absence read.

Auditability also changes the deletion order. Preserve the workflow record while removing the media objects, then minimize that record according to the organization's retention rules after the request is closed. The retained proof should establish which identifiers were processed and what was observed, without retaining the deleted photos themselves.

Roll back the workflow, never the deletion

Deletion is irreversible, so rollback means halting safely and resuming from durable state. If verification does not confirm absence, keep the account in a restricted, deletion-pending state, stop downstream use of its media, and retry from the first asset that lacks proof. Do not recreate an image to make an application rollback look tidy.

Before rollout, run the worker against disposable assets and test four cases: all IDs verify absent, one delete is rate-limited, one read still finds an asset, and the worker stops halfway through the list. The last case is the important one. Restarting must skip IDs already recorded as verified, continue the remainder, and produce one coherent audit trail tied to the original request.

Roll out with a concurrency cap. Watch provider throttling, queue age, and database contention, then raise the cap only while the verified-completion SLO retains margin. This is where a capacity-planning reflex pays off: bandwidth used by normal avatar uploads and OCR should be budgeted alongside deletion traffic, since an account-closure spike should not starve the very reads needed to prove completion.

The final operational rule stays small: snapshot, delete, read, record. Everything else supports those four verbs.

References

Top comments (0)