InstagramProfile picture upload

100%

Profile picture upload.

A signed-in user swaps the round photo that represents them everywhere in the app. They pick an image, frame their face in a circle, watch it upload, and see the new face in the header, the nav and their posts the moment the server accepts it, without a reload and without the page freezing while a 12 megapixel photo is shrunk.

Intermediate45 minUpdated 2 Oct 2026

Builds on Email and password login.

Requirements exploration.

The web flow for changing your own profile photo. Storage, server resizing and moderation sit behind one upload endpoint.

Clarifying questions

Q1
Entry pointsWhere does the flow start?
Change photo on Edit profile, and the Add a profile photo card on a new profile. Both open the same dialog.
Q2
InputWhich files must we accept, and how big?
Phone or laptop photos, typically 2 to 8 MB, up to about 50 megapixels. JPEG, PNG and WebP; HEIC when the browser can decode it.
Q3
OutputWhat does the server want back?
One square image. We send 640 by 640 pixels, enough for the largest avatar (a 150 CSS pixel circle) on a 3x screen. The server makes smaller sizes.
Q4
EditingHow much editing is in scope?
Framing only, with a circle mask, drag and zoom. Filters and aspect choices belong to the post editor.
Q5
NetworkWhat networks should we design for?
Office Wi-Fi to a weak mobile tether. The upload must show progress, survive a drop and be cancellable.
Q6
After uploadWhen does everyone see the new photo?
The user at once; others once the server has processed and approved it.
Q7
RemoveCan the user go back to no photo?
Yes. Remove current photo returns the default silhouette, with a short undo.
Q8
CameraDo we need a live camera capture screen?
No. On phones the file input already offers the camera.

Functional requirements

#1User can choose a photo with a file picker or by dropping it on the dialog.
#2User is told at once, with the reason, when a file is the wrong type, too large or too many pixels.
#3User sees the photo the right way up and frames it in a circle by drag, zoom or keyboard.
#4User sees upload progress and can cancel at any time.
#5A failed upload can be retried without picking, cropping or compressing again.
#6On success, every avatar of the user on the page shows the new photo without a reload.
#7User can remove the current photo and undo that for a few seconds.
#8A new profile with no photo offers an Add a profile photo card that opens the same flow.

Non-functional requirements

No.AreaRequirementTarget / measure
01ResponsivenessThe page never freezes while a large photo is processed.
No main-thread task over 50 ms from pick to uploadCrop drag at the display's frame rate
Mid-range laptop and phone, 12 MP JPEG.
02Upload size and timeSend only what the largest avatar needs.
Upload body under 150 KBp95 under 3 s on a 1 Mbps uplink
03MemoryOne large photo must not crash a low-memory tab.
Peak decoded pixels under 100 MBEvery object URL revoked
04ReliabilityA retry never creates a second avatar or re-runs the crop.
Same idempotency key on every retry0 duplicate avatars
05AccessibilityThe whole flow works by keyboard and screen reader.
WCAG 2.2 AAOne announcement per outcome
06PrivacyGPS and other metadata stay on the device.
0 EXIF blocks in the uploaded body

Sizing the photo

Why the client, not the server, should do the shrinking.

One avatar, before and after

Assumptions
Original file
5.3 MBIMG_4471.jpg, 12 MP phone JPEG (1 MB = 1,000,000 bytes here)
Original pixels
4032 × 3024
Output we send
640 × 640
Output size
74 KBWebP at quality 0.82 for this photo; faces with soft backgrounds compress well
Slow uplink
1 MbpsA weak tether or a crowded café
Working
  1. Decoded full photo in memory4032 × 3024 × 4 bytes (RGBA)≈ 48.8 MBfrom Original pixels · Why the full bitmap lives briefly, in a worker.
  2. Bytes saved5.3 MB ÷ 74 KB≈ 72× smallerfrom Original file and Output size
  3. Upload time, original5.3 MB × 8 ÷ 1 Mbps≈ 42 sfrom Original file and Slow uplink
  4. Upload time, compressed74 KB × 8 ÷ 1 Mbps≈ 0.6 sfrom Output size and Slow uplink
What it means
  • Compressing on the device turns 42 s into under a second, so one request with progress is enough; resumable chunks are not worth it.
  • The 49 MB decoded bitmap is the real hazard; never copy it, close it after the cut, keep it off the main thread.

Scope

In scope
File input, drop zone and checks before decode
Local preview, EXIF orientation and circle crop
Resize and encode in a worker, with a fallback
One upload request with progress, cancel and retry
Processing wait, then the avatar swap everywhere
Remove current photo with undo
Out of scope
Filters, rotation and aspect ratioscrop-and-filter
Resumable, chunked uploadswhatsapp/photo-and-voice-messages
Many files at once with a queueairbnb/list-your-place
Server-side resizing and moderation internalsMedia API team
Live camera capture with getUserMediathe file input offers the camera on phones
Was this section helpful?

High level design.

The heavy pixels live in a worker, the network in one service, and the avatar in one store entity. The dialog only coordinates.

Component diagram

Step through to follow one photo from the picker to every avatar on the page.

Profile photo flow components

Profile photo flow components. The numbered component cards that follow describe each part.
Profile photo flow componentsComponents: 1. EditProfilePage (Route shell for /accounts/edit and the new-profile card. Opens the photo dialog, shows the result toast and the undo for Remove.), 2. AvatarUploader (The dialog's controller. A labelled file input with a drop zone, the checks before decode, the preview, and the upload row with progress, Cancel and Try again.), 3. CropDialog (Circle mask with drag, zoom slider and keyboard pan. Keeps the crop in the photo's own pixels, never screen pixels.), 4. ImageWorker (A dedicated worker that decodes, applies EXIF orientation, cuts the crop, scales to 640 px and encodes WebP (else JPEG). No main-thread script holds the full-size pixels.), 5. UploadService (The only caller of the avatar endpoints. One request with progress, a stall timer and an idempotency key retries, cancels and polls.), 6. ViewerStore (The signed-in user as one entity. Holds a pending local avatar during the upload, the confirmed one after, and tells other tabs.), 7. Avatar (Every round photo of the viewer (nav, header, feed and comment rows). Reads viewer.avatar by selector, so one store change re-renders all.), 8. Media API (Black box: re-checks, stores, makes sizes, moderates and publishes a new content-addressed URL.).

Browser tab

1EditProfilePage/accounts/edit · toast · undo

Worker thread

preview URL ⇄ CropRect

{ file, crop } ⇄ 74 KB blob

upload(blob, key)

POST /api/me/avatar
GET …/uploads/{id}

202 processing → ready + url

pending · confirmed · rolled back

viewer.avatar

4ImageWorker
createImageBitmap(file)
OffscreenCanvas 640 px → WebP

2AvatarUploader
FileInput + DropZone · PreflightCheck
UploadRow · progress · Cancel · Try again

3CropDialog
CircleMask · ZoomSlider 1x to 4x

6ViewerStore
Viewer { id, username, avatar }
avatar.pending while uploading

7Avatar × n
nav rail · profile header · feed rows

5UploadService
XHR + upload progress
Idempotency-Key · stall timer · retry

8Media API
black box · store · sizes · moderation

5 steps. Step through to see what one change does to these components.

The photo dialog, state by state

One dialog carries the whole flow. Step through the tabs to follow Kavya from a refused scan to her new avatar.

Change profile photo

Her first pick, scan-0007.tif, is 31 MB. File.size alone rules it out, so nothing is read or decoded.

Refused file. Media editor “Change profile photo”, crop mode. Top bar: “Cancel” and “Save”. Image: “Kavya at the pottery wheel” (1.33 tall) under a circle mask. Tool tabs: Zoom (active). Slider “Zoom” from “1x” to “4x”. Status words: queued “Waiting”, validating “Checking photo…”, compressing “Resizing on this device…”, uploading “Uploading”, processing “Finishing up…”, done “Saved”, paused “Waiting for connection”, failed “Upload failed”, cancelled “Cancelled”, retry “Try again”, cancel “Cancel”. Rejected before reading: “scan-0007.tif is 31 MB. Choose a photo under 25 MB (JPEG, PNG, WebP or HEIC).”. Note on validation: role=alert · focus stays on Choose a photo

  1. Checks before decode2AvatarUploader
  2. Crop in image pixels3CropDialog
  3. Zoom slider3CropDialog
  4. Resize and encode in a worker4ImageWorker
  5. Progress, retry and cancel5UploadService

Where the dialog lives

The dialog opens from Edit profile. Closing it hides the upload row but does not stop the upload; only Cancel does.

Edit profile

Change photo is a real button; focus returns to it when the dialog closes.

At rest. Form “Edit profile”. Fields: Profile photo = “kavya.paints · Kavya Rao” (read-only) [Change photo]; Name = “Kavya Rao”; Username = “kavya.paints”; Bio = “Wheel-thrown mugs, mostly blue.”. Button: “Submit”. Links: Remove current photo.

  1. Avatar with pending ring7Avatar
  2. Change photo opens the dialog1EditProfilePage
  3. Remove with undo1EditProfilePage

The upload's life

The dialog's status is one small state machine. Every screen above is a state and every button an event, which makes Cancel and Try again safe at any moment.

One photo, from pick to swap

States of2AvatarUploader

One photo, from pick to swap. 12 states, 18 transitions. The table below lists them.
One photo, from pick to swapThe states of AvatarUploader. 12 states, 18 transitions. The table below lists them.

file chosen

check fails / show reason

checks pass / object URL

file chosen

Save / post file + crop to worker

decode fails / revoke URL

blob ready / new key · send

network error or 5xx [attempt ‹ 3]

timer / same blob + key

network error or 5xx [attempt = 3]

Try again / same blob + key

202 processing / start polling

status ready / swap URL · revoke blob

status rejected / roll back avatar

no verdict in 30 s

Cancel / abort worker job

Cancel / xhr.abort()

Cancel / clear timer

Idle

Checking

Rejected

Framing

Compressing

Uploading

Waiting to retry

Failed

Processing

Done

Declined

Cancelled

8 steps. The run from the tabs above, with one dropped connection.

Failed keeps the blob and the key, so Try again goes straight back to uploading.

Transitions of One photo, from pick to swap
From → ToEventGuardAction
Idle → Checkingfile chosen
Checking → Rejectedcheck failsshow reason
Checking → Framingchecks passobject URL
Rejected → Checkingfile chosen
Framing → CompressingSavepost file + crop to worker
Compressing → Rejecteddecode failsrevoke URL
Compressing → Uploadingblob readynew key · send
Uploading → Waiting to retrynetwork error or 5xxattempt < 3
Waiting to retry → Uploadingtimersame blob + key
Uploading → Failednetwork error or 5xxattempt = 3
Failed → UploadingTry againsame blob + key
Uploading → Processing202 processingstart polling
Processing → Donestatus readyswap URL · revoke blob
Processing → Declinedstatus rejectedroll back avatar
Processing → Failedno verdict in 30 s
Compressing → CancelledCancelabort worker job
Uploading → CancelledCancelxhr.abort()
Waiting to retry → CancelledCancelclear timer
Idlestart
Dialog open, nothing chosen
Checking
Size, first 16 bytes, header pixels
Rejectederror
Reason shown; pick again
Framing
Object URL preview, crop in image px
Compressing
Worker decodes, cuts, encodes
Uploading
One XHR, progress, stall timer
Waiting to retry
1 s, then 4 s
Failed
Blob and key kept
Processing
Polling the upload's status
Doneend
Declinederror
Moderation said no; old avatar back
Cancelledend
Request aborted, URLs revoked

Sequence diagram

The happy path with one automatic retry.

Save to swapped avatar

Save to swapped avatar, as an ordered list of steps:
Save to swapped avatar16 steps between Kavya, CropDialog, AvatarUploader, ImageWorker, UploadService, Media API, ViewerStore. The steps are listed as text after the diagram.ViewerStoreMedia APIUploadServiceImageWorkerAvatarUploaderCropDialogKavyadecode · orient · cut · scale · encode WebPnetwork error at 62% · wait 1 severy Avatar re-renders · blob URL revokedzoom 1.6x · drag · Save1crop { x 567, y 610, size 1890 }2postMessage { file, crop, size 640 }3{ blob 74 KB, type image/webp }4avatarPending(blob URL)5upload(blob, key 7d2e…)6POST /api/me/avatar (attempt 1)7POST again, same Idempotency-Key8202 { upload_id, status: processing }9GET /api/me/avatar/uploads/{id}10200 { status: ready, avatar }11Ready(avatar)12avatarConfirmed(avatar)13
  1. Kavya → CropDialog: zoom 1.6x · drag · Save
  2. CropDialog → AvatarUploader (reply): crop { x 567, y 610, size 1890 }
  3. AvatarUploader → ImageWorker: postMessage { file, crop, size 640 }
  4. Note over ImageWorker: decode · orient · cut · scale · encode WebP
  5. ImageWorker → AvatarUploader (reply): { blob 74 KB, type image/webp }
  6. AvatarUploader → ViewerStore: avatarPending(blob URL)
  7. AvatarUploader → UploadService: upload(blob, key 7d2e…)
  8. UploadService → Media API: POST /api/me/avatar (attempt 1)
  9. Note over UploadService: network error at 62% · wait 1 s
  10. UploadService → Media API: POST again, same Idempotency-Key
  11. Media API → UploadService (reply): 202 { upload_id, status: processing }
  12. UploadService → Media API: GET /api/me/avatar/uploads/{id}
  13. Media API → UploadService (reply): 200 { status: ready, avatar }
  14. UploadService → AvatarUploader (reply): Ready(avatar)
  15. AvatarUploader → ViewerStore: avatarConfirmed(avatar)
  16. Note over ViewerStore: every Avatar re-renders · blob URL revoked
When it goes wrongEach failure has one owner and one thing the user sees.
CodeOutcomeKindWhat happensReacts
PREtoo large · wrong type · too many pixelserrorReason shown; nothing decoded.2AvatarUploader
DECODEunreadable photoerrorAsk for a JPEG or PNG.4ImageWorker
NETdropped or stallederrorTwo quiet retries, then Try again.5UploadService
202processingchallengeFinishing up, polling every 1.5 s.5UploadService
200readysuccessNew URL in every avatar.6ViewerStore
200status: rejected (moderation)errorOld avatar returns.6ViewerStore
Was this section helpful?

Data model.

Three owners, three lifetimes. Upload state dies with the dialog, the worker's job with its reply; only an avatar reference outlives both.

The File and the crop go to the worker, the blob comes back, and only an AvatarRef survives the dialog.keywordmodelfieldprimitivevalue
// 01 · AvatarUploader
// Lives while the dialog's upload lives.
type UploadState = {
attempt: int // 1 to 3 automatic, reset by Try again
crop?: CropRect
error?: UploadError
file?: FileMeta
idempotencyKey?: uuid // made once per export
output?: Blob // kept for retries
previewUrl?: blob URL
progress: 0..1
status: UploadStatus
uploadId?: string
}
// Read without decoding.
type FileMeta = {
bytes: int
height: int // after orientation
name: string
sniffed: jpeg | png | webp | heic
width: int // after orientation
}
// Oriented source pixels, never CSS pixels.
type CropRect = {
size: int // square side, at least 320
x: int
y: int
}
↓ Save posts a ResizeJob
// 01 · AvatarUploader, enums
type UploadStatus =
| "idle" | "checking"
| "rejected" | "cropping"
| "compressing" | "uploading"
| "waiting" | "failed"
| "processing" | "done"
| "declined" | "cancelled"
type UploadError = {
code: too_large | bad_type | too_many_pixels | decode_failed | network | server | declined
retryable: bool
}
// 02 · ImageWorker messages
// The File goes by structured clone, bytes not copied.
message ResizeJob = {
crop: CropRect
file: File
jobId: int
out: 640
quality: 0.82
}
message ResizeResult = {
blob?: Blob
code?: decode_failed
jobId: int
ok: bool
}
↑ the blob returns to the dialog
// 03 · ViewerStore
// One entity, read by every Avatar.
type Viewer = {
avatar: AvatarRef | null
full_name: string
id: string
username: string
}
// What survives the dialog.
type AvatarRef = {
id: string
pending: bool // true while the server decides
previous?: AvatarRef // for rollback and undo
url: url // blob URL while pending, CDN URL after
}

Crop math in image pixels

The circle is a fixed 320 CSS pixel square. The user moves the photo underneath, so the cropper converts each gesture into a square of the photo's own pixels.

From a gesture to a CropRect

// W, H: the photo's size after EXIF orientation (3024 × 4032 for Kavya).
// V: the viewport square in CSS px (320). zoom: 1 to maxZoom.
// pan: how far the photo was dragged from centred, in CSS px (positive y = dragged down).
export function cropRect(W: number, H: number, V: number, zoom: number, pan: { x: number; y: number }) {
  const short = Math.min(W, H);
  const perCssPx = short / (V * zoom);   // image px under one CSS px
  const size = Math.round(short / zoom); // the square we keep
  const clamp = (v: number, max: number) => Math.min(Math.max(v, 0), max);
  return {
    size,
    x: clamp(Math.round((W - size) / 2 - pan.x * perCssPx), W - size),
    y: clamp(Math.round((H - size) / 2 - pan.y * perCssPx), H - size),
  };
}
// Kavya: zoom 1.6, pan.y 78 → { size: 1890, x: 567, y: 610 }
// maxZoom = min(4, short / 320), so the kept square is never under 320 px.

What zoom means in pixels

Photo (oriented)ZoomSquare keptScale to 640 pxNote
3024 × 4032 phone photo1x3024 px0.21Whole width; a big downscale, so smoothing quality matters
3024 × 4032 phone photo1.6x1890 px0.34Kavya's framing
3024 × 4032 phone photo4x756 px0.85The slider's top end
900 × 1200 older photo2.81x320 px2.0Zoom capped here, so it is never upscaled more than 2×

The worker's job

self.onmessage = async ({ data: { jobId, file, crop, out, quality } }) => {
  let bmp;
  try {
    // Decodes here, off the main thread; EXIF orientation is applied by default.
    bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
    const canvas = new OffscreenCanvas(out, out);
    const ctx = canvas.getContext('2d');
    ctx.imageSmoothingQuality = 'high';
    ctx.drawImage(bmp, crop.x, crop.y, crop.size, crop.size, 0, 0, out, out);
    bmp.close(); // frees about 49 MB now, not at the next GC
    bmp = undefined;
    let blob = await canvas.convertToBlob({ type: 'image/webp', quality });
    // An unsupported type silently comes back as PNG: check, then fall back.
    if (blob.type !== 'image/webp') {
      blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
    }
    self.postMessage({ jobId, ok: true, blob });
  } catch {
    bmp?.close();
    self.postMessage({ jobId, ok: false, code: 'decode_failed' });
  }
};
Was this section helpful?

Interface definition.

Three endpoints carry the flow, and only UploadService calls them. Inside the page, the uploader uses worker messages and a few store actions.

Transport choice

Used:One XMLHttpRequest with a raw bodyThe only API with upload progress events everywhere (Baseline since 2015). fetch and resumable chunks are weighed in Trade-offs.
Not used:Direct to storage with a presigned URLSaves a hop for big files, but adds a request, storage CORS and a second place to validate.
Not used:multipart/form-dataOnly needed for fields beside the file. The crop is already applied, so the body is just the image.

Server to client APIs

1POST/api/me/avatar

Stores a new avatar and starts processing. The Idempotency-Key makes a resend return the same upload instead of a second one.

Request
The body is the 640 × 640 image bytes. The server checks type, size and dimensions again; client checks are for speed, not trust.
Headers
Content-Type: image/webpX-CSRF-Token: <token>Idempotency-Key: "7d2e6c41-0b9a-4f3e-a8d2-5c19e0b7f6aa"
Credentials
same-origin cookies
Response
{
"upload_id": "up_51ka9",
"status": "processing",
"poll_after_ms": 1500
}
The bytes are stored; sizes and moderation are still running.
2GET/api/me/avatar/uploads/{upload_id}

The upload's processing status. Polled every poll_after_ms while the dialog waits, for at most 30 s.

Request
Credentials
same-origin cookies
Response
{ "status": "processing", "poll_after_ms": 1500 }
3DELETE/api/me/avatar

Removes the current photo. Sent only after the undo toast closes, so Undo needs no request.

Request
Headers
X-CSRF-Token: <token>
Credentials
same-origin cookies
Response204
Idempotent; a repeat is harmless.

Error cases

Every error has one shape. UploadService maps it to a typed result; the uploader decides what the user sees.

{ "error": { "code": "too_large", "message": "This photo is larger than 2 MB.", "retryable": false } }
HTTPTypeBody codeClient behaviour
400error
missing_idempotency_key
A client bug; report it.
401challenge
not_authenticated
Session expired. Keep the blob, open the login modal, resend with the same key.
403retry
csrf_failed
Refresh the token once and resend once.
409retry
request_in_progress
The first request with this key is still running. Wait poll_after_ms and resend with the same key.
413error
too_large
Unlikely at 640 px. Re-encode once at quality 0.7, then give up.
415error
unsupported_type
The server could not read the bytes. Ask for another photo.
422error
idempotency_key_reused
Same key, different bytes, so a client bug. Only a new crop gets a new key.
429error
rate_limited+ retry_after_s
Too many changes. Row says “Try again in 2 min” with a countdown.
5xxnetwork
server_error · timeout · offline
Two quiet retries after 1 s and 4 s, then Try again. A stall counts as a timeout.

Client to client communication

The cropper reports by callback, the worker by messages, and everything the rest of the app must know goes through two stores. Pick a scenario and step through it.

Step 1 of 5
The input hands over a File; only its size and first 16 bytes are read.
example.com/accounts/edit
CancelChange profile photoSave
Kavya at the pottery wheel
Zoom
Zoom1x4x
Actions dispatched
1
upload/fileChosenfrom AvatarUploader, input change or drop
{ name, bytes }
Upload state + Viewer
statuschecking
fileIMG_4471.jpg · 5.3 MB
blobUrlnull
attempt0
avatarUrlimg…/2b77a0/640.webp
All actions
upload/fileChosenAvatarUploader, input change or drop{ name, bytes }
upload/checkedAvatarUploader, after the preflight{ sniffed, width, height, previewUrl }
upload/exportedImageWorker reply{ blob, type }
upload/failedUploadService{ code, retryable }
upload/retriedAvatarUploader, Try againnone (same blob and key)
upload/cancelledAvatarUploader, Cancelnone
viewer/avatarPendingAvatarUploader{ url: blob URL, previous }
viewer/avatarConfirmedAvatarUploader, when UploadService reports ready{ avatar: { id, url } }
viewer/avatarRolledBackAvatarUploader{ reason }
viewer/avatarRemovedEditProfilePage{ previous }
viewer/removeUndoneEditProfilePage, Undonone

UI component API

AvatarUploader is mounted by the Edit profile dialog and the Add a profile photo card. Click a prop to see what it controls.

<AvatarUploader
/>
Prop
maxBytes
Default
25_000_000
Why
The input limit, checked against File.size before anything is read.
Preview
Change profile photo
scan-0007.tif is 31 MB. Choose a photo under 25 MB.
Profile photo
No photo chosenChoose a photo
JPEG, PNG, WebP or HEIC, up to 25 MB
Save
Cancel
A 31 MB scan is refused at once.
Child components
FilePicker
acceptonFile<input type="file"> with a visible label
The drop zone wraps the real input; drop, choose and paste call the same onFile.
CropDialog
srcnaturalSizeminSideonChange(CropRect)
The zoom slider is role=slider with aria-valuetext; arrows pan the focused surface, plus and minus zoom.
UploadRow
namesizestatusprogressonCancelonRetry
role=progressbar with aria-valuenow; the outcome goes to a role=status region once.
Was this section helpful?

Optimizations.

A rubric picks the areas that decide whether this flow feels solid; the rest get a line each at the end.

Priority rubric

Each area is scored 1 to 3 on three questions; the top five become deep dives.

RankAreaImpact if it failsUsers affectedHow often it mattersScoreDecision
1Main-thread responsiveness3 of 33 of 33 of 39Deep diveEvery upload decodes 10+ megapixels; on the main thread the dialog locks up.
2Checks before decode and memory3 of 33 of 32 of 38Deep diveOne oversized or hostile file can crash the tab.
3Network resilience2 of 33 of 33 of 38Deep diveDrops and stalls are routine on mobile.
4Avatar swap and caching2 of 33 of 33 of 38Deep diveA stale cached face reads as failure.
5Accessibility3 of 32 of 33 of 38Deep diveA mouse-only crop or silent progress blocks some users outright.
6Security and privacy3 of 31 of 31 of 35Handled brieflyThe server re-validates; the client mostly must not leak metadata.
7Internationalisation1 of 32 of 31 of 34Handled brieflyA few strings and number formats.
8Offline1 of 31 of 31 of 33Handled brieflyFailing clearly is enough.
1

Deep dive: checks before decode, and memory

A file input hands over anything. The cheapest checks run first, and nothing is decoded until all pass.

The first 12 bytes decide the type

JPEG
  1. SOI + marker, 3 bytes, Signature checked, value FF D8 FF
  2. APP1 (EXIF) usually next, 9 bytes, Not read for the type
PNG
  1. signature, 8 bytes, Signature checked, value 89 50 4E 47 0D 0A 1A 0A
  2. IHDR length, 4 bytes, Not read for the type
WebP
  1. RIFF, 4 bytes, Signature checked, value 52 49 46 46
  2. size, 4 bytes, Length field, skipped
  3. WEBP, 4 bytes, Format brand checked, value 57 45 42 50
HEIC
  1. box size, 4 bytes, Length field, skipped
  2. ftyp, 4 bytes, Signature checked, value 66 74 79 70
  3. heic · heix · mif1, 4 bytes, Format brand checked
The scan (TIFF)
  1. II*, 4 bytes, Not an accepted signature, value 49 49 2A 00
  2. 8 bytes, Not read for the type
  • Signature checked
  • Format brand checked
  • Length field, skipped
  • Not read for the type
  • Not an accepted signature
Start

As it starts. 3 steps follow.

The uploader reads Blob.slice(0, 16) and matches signatures, ignoring the name and File.type, which come from the extension.
Time to a refusal< 50 msPeak decoded memory< 100 MBObject URLs left alive0
No.ConcernRiskApproachOwned by
01Size firstA 31 MB file is read just to be refused.File.size needs no read. Over 25 MB is refused at once.2AvatarUploader
02Type from bytesA mislabelled file reaches the decoder, or a valid photo with an odd extension is refused.Match signatures in the first 16 bytes. accept is only a picker hint, and File.type comes from the extension.2AvatarUploader
03Pixel cap before decodeA small file that declares an enormous image (a decompression bomb) or a 100 MP panorama allocates hundreds of megabytes on decode.Read width and height from the header (PNG IHDR, JPEG frame marker, WebP chunk) in the first 128 KB; refuse over 50 MP. If unclear, the worker checks the bitmap size before drawing.2AvatarUploader4ImageWorker
04Preview without copiesA data URL copies the file into base64, a third larger, on the main thread.URL.createObjectURL(file) points the preview at the file; the img uses decoding="async".2AvatarUploader
05Revoke every object URLEach object URL keeps its blob alive until unload; repeated picks pile up.Revoke the preview URL on a new pick, on close and on done; revoke the pending avatar URL once the CDN image has loaded.2AvatarUploader6ViewerStore
06Close bitmaps at onceA 49 MB ImageBitmap waits for garbage collection while the encoder allocates more.close() right after the crop is drawn, and in the error path.4ImageWorker
07Undecodable formatsAn iPhone HEIC passes the type check but this browser cannot decode it.When createImageBitmap rejects, ask for a JPEG or PNG. Never upload the original instead; the server would need to accept 25 MB bodies.4ImageWorker2AvatarUploader
2

Deep dive: keeping the main thread free

Decoding and encoding a phone photo is a few hundred milliseconds of solid work. Where it runs decides whether Cancel keeps responding.

From Save to the swapped avatar

Scenario 1 of 2: As described.

Timeline as a list

From Save to the swapped avatar: 4 lanes, from Save to Save+2,800 ms.

  1. Save · Main thread → ImageWorker, arriving Save+5 ms: file + crop
  2. Save+5–Save+245 ms · ImageWorker · decode + orient
  3. Save+50 ms · all lanes · 50 ms long-task line (deadline)
  4. Save+245–Save+315 ms · ImageWorker · cut + scale
  5. Save+315–Save+405 ms · ImageWorker · encode WebP
  6. Save+405 ms · ImageWorker → Main thread, arriving Save+410 ms: blob
  7. Save+410 ms · Main thread · pending avatar
  8. Save+412–Save+1,012 ms · Upload request · 74 KB at 1 Mbps
  9. Save+1,012–Save+2,100 ms · Media API · sizes + review
  10. Save+1,012 ms · Media API · 202 processing (tick)
  11. Save+2,512–Save+2,545 ms · Main thread · preload + swap
  12. Save+2,512 ms · Main thread · poll: ready (ok, ok)
Illustrative timings, mid-range laptop, 1 Mbps uplink. With the worker the main thread only posts a message and paints.
Main-thread task during export< 50 msCrop dragdisplay frame rate
No.ConcernRiskApproachOwned by
01Decode in the worker, not on the pageDecoding and drawing 12 MP on the main thread can block input for hundreds of milliseconds.Post the File itself (cloneable; bytes not copied) and call createImageBitmap in the worker.2AvatarUploader4ImageWorker
02One full-size bitmap, brieflyCopying pixels between threads doubles memory and time.The full bitmap never leaves the worker; only the encoded Blob returns. A bitmap that must cross is transferred, not copied.4ImageWorker
03Preview stays cheapRedrawing the photo through a canvas on every drag frame.The preview is an img moved by CSS transform; only CropRect numbers change during a drag.3CropDialog
04Feature-detect, then fall backWithout OffscreenCanvas 2D in workers the export fails.Check OffscreenCanvas and its 2d context at worker start. Otherwise use a page canvas, with createImageBitmap's resize options shrinking first so the blocking part is short.4ImageWorker2AvatarUploader
05Downscale qualityA one-step 0.21 scale looks jagged on hair.imageSmoothingQuality high; if still poor, halve in steps to within 2× of 640 px, then draw once more.4ImageWorker
06Worker lifetimeStart-up delays the first Save; an idle worker keeps its heap.Create it when the dialog opens; terminate it on close or 30 s after the last job.2AvatarUploader
3

Deep dive: progress, retry and cancel

One small request still has to show honest progress, notice a stall, retry without duplicates and stop when asked.

Upload time by uplink speed

  • Original 5.3 MB
  • Avatar 74 KB
Upload time by uplink speedShrinking first turns 42 seconds on a 1 Mbps uplink into 0.6 seconds, at every speed.10 ms100 ms1 s10 s100 s0.1110100p95 target 3 sOriginal 5.3 MBAvatar 74 KBSeconds to send (s)Uplink (Mbps)Upload time by uplink speedShrinking first turns 42 seconds on a 1 Mbps uplink into 0.6 seconds, at every speed.10 ms100 ms1 s10 s100 s0.1110100p95 target 3 sOriginal 5.3 MBAvatar 74 KBSeconds to send (s)Uplink (Mbps)
Pure transfer time, size × 8 ÷ speed, ignoring overhead and latency.
Data
Uplink (Mbps)Original 5.3 MB (s)Avatar 74 KB (s)
0.584.81.18
142.40.59
221.20.3
58.480.12
104.240.06
202.120.03
  • p95 target 3 s: Seconds to send (s) = 3

One attempt, with progress, a stall timer and cancel

export function uploadOnce(blob: Blob, key: string, csrf: string,
                           signal: AbortSignal, onProgress: (p: number) => void) {
  return new Promise<Response202 | Ready>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    let stall = setTimeout(() => xhr.abort(), 20_000);
    const touch = () => { clearTimeout(stall); stall = setTimeout(() => xhr.abort(), 20_000); };
    xhr.upload.onprogress = (e) => { touch(); if (e.lengthComputable) onProgress(e.loaded / e.total); };
    xhr.onload = () => { clearTimeout(stall); xhr.status < 300 ? resolve(JSON.parse(xhr.responseText))
                                                             : reject(httpError(xhr)); };
    xhr.onerror = () => { clearTimeout(stall); reject({ code: 'network', retryable: true }); };
    xhr.onabort = () => { clearTimeout(stall);
      reject(signal.aborted ? { code: 'cancelled' } : { code: 'stalled', retryable: true }); };
    signal.addEventListener('abort', () => xhr.abort(), { once: true });
    xhr.open('POST', '/api/me/avatar');
    xhr.setRequestHeader('Content-Type', blob.type);
    xhr.setRequestHeader('X-CSRF-Token', csrf);
    xhr.setRequestHeader('Idempotency-Key', `"${key}"`); // a quoted string; the same key on every attempt
    xhr.send(blob);
  });
}
Automatic retries2 (1 s, 4 s)Stall timeout20 s without progressProcessing wait≤ 30 s, poll 1.5 s
No.ConcernRiskApproachOwned by
01Honest progressThe bar sits at 100% while the server works, and the user thinks it hung.Upload progress covers sending bytes only. At 100% the row switches to Finishing up….5UploadService2AvatarUploader
02Stall, not just failureOn a weak tether the request neither fails nor moves.A 20 s timer resets on every progress event; when it fires the attempt is aborted as a retryable failure.5UploadService
03Retry the same thingA response lost after the server stored the photo leads to a resend and a second avatar.One idempotency key per export, reused by every retry and Try again. A new crop gets a new key.5UploadService8Media API
04Back off, then hand overInstant retries hammer a struggling server and drain the battery.Two retries after 1 s and 4 s for network errors, stalls and 5xx, then Try again. Other 4xx than 401, 403 csrf and 409 are never retried.5UploadService
05Wait for the networkRetrying offline burns attempts.If navigator.onLine is false, hold the next attempt until the online event; the row says Waiting for connection.5UploadService
06One cancel for everythingCancel stops the request but the worker keeps encoding, or the pending avatar stays.One AbortController per upload. Its signal aborts the XHR, clears the retry timer and drops a running worker job; the uploader rolls the avatar back and revokes the URLs.2AvatarUploader5UploadService4ImageWorker
07Leaving mid-uploadClosing the tab mid-upload loses the change silently.While uploading, a beforeunload prompt asks to stay. Once processing starts the server has the photo, so no prompt.1EditProfilePage

Cancel mid-upload

Cancel is a real button, enabled for the whole upload.

Uploading. Media editor “Change profile photo”, upload mode. Image: “Kavya's new avatar” (1:1) (mask circle when cropping). Files: 1. 1. IMG_4471.jpg (74 KB): uploading 48% (“Uploading”); offers “Cancel”. Status words: uploading “Uploading”, failed “Upload failed”, cancelled “Cancelled. Your photo hasn't changed.”, retry “Try again”, cancel “Cancel”.

  1. One AbortController5UploadService
4

Deep dive: the avatar swap and caching

The new face has to replace every copy of the old one, and no cache may keep showing the old one.

No.ConcernRiskApproachOwned by
01One entity, many viewsThe nav, header and feed rows each hold their own avatar URL, and some are missed.Every avatar of the viewer reads viewer.avatar by selector. Feed rows store an author id, which resolves to the same entity.6ViewerStore7Avatar
02A new URL, not a query stringWith ?v=2 on one URL, caches that ignore query strings and other copies of the old URL show the old face.The server returns a URL whose path holds a content hash. Nothing needs invalidating; the CDN sends a year's max-age with immutable.8Media API6ViewerStore
03Optimistic, but only for the ownerWaiting for processing feels slow; showing an unreviewed photo to others is not acceptable.The local blob URL becomes the viewer's avatar with a pending ring, in this tab only. Others see the old URL until the server publishes.2AvatarUploader6ViewerStore7Avatar
04No flash on swapSwapping in the CDN URL shows a blank circle while it downloads.Load the new URL into an Image and await decode() before writing it; then revoke the blob URL.6ViewerStore
05RollbackA cancelled, failed or declined upload leaves the new face on screen.The pending AvatarRef keeps previous; one write restores every view.6ViewerStore
06Other tabsA second open tab keeps the old face until it reloads.On confirm, post avatar-changed on a BroadcastChannel; other same-origin tabs write it into their store.6ViewerStore
07Broken image fallbackAn old URL in stale data stops resolving after a replace or remove.The server keeps old URLs serving for a while; Avatar falls back to initials on a load error.7Avatar8Media API
5

Deep dive: accessibility

A circle crop is pointer-first. Each part needs a keyboard and screen reader equivalent.

No.ConcernRiskApproachOwned by
01The picker is a real inputA styled div with a click handler is unreachable by keyboard.A styled label for a real input type="file". Drop and paste are extras, never the only way in.2AvatarUploader
02Zoom as a sliderScreen reader users hear 0.2 or nothing.role="slider" (or a native range input) with aria-valuemin, max, now and aria-valuetext such as 1.6 times. Arrows step 0.1x, Page Up and Down 0.5x, Home and End go to the ends.3CropDialog
03Panning without a pointerFraming needs a drag, which some users cannot do.The crop surface is focusable, labelled with its keys. Arrows move the photo 1% of its size, Shift 10%; plus and minus zoom; Reset recentres.3CropDialog
04Progress without chatterAnnouncing every percent floods the screen reader.The row is role="progressbar" with aria-valuenow. Only the outcome (saved, failed, cancelled) goes to a role="status" region, once.2AvatarUploader
05Errors where focus isA refused file shows red text far from focus.The reason is tied to the input with aria-describedby and announced as an alert; focus stays on Choose a photo.2AvatarUploader
06Focus in and out of the dialogFocus lands at the top of the page when the dialog closes.The modal traps focus and returns it to Change photo on close. Escape cancels before the upload and hides the dialog after.1EditProfilePage
07Reduced motionZoom easing and a pulsing ring bother some users.Under prefers-reduced-motion, zoom jumps without easing and the ring is static.3CropDialog7Avatar

The other areas, briefly

AreaWhat we doOwner
PrivacyRe-encoding from a canvas drops the EXIF block, so GPS position and device details never leave the device. The server strips metadata anyway.ImageWorker
SecurityCSRF token plus session cookie. The server repeats every check, decodes in a sandbox and serves images from a separate CDN origin with a fixed Content-Type.UploadService, Media API
InternationalisationStatus words come from the labels prop; numbers use Intl.NumberFormat; the dialog mirrors under dir="rtl".AvatarUploader
OfflineNo queue across reloads. Offline at Save waits for the online event.UploadService
TelemetryRefusals by reason, worker vs fallback, encode time, output size, attempts, outcome. Never file names or bytes.AvatarUploader
Was this section helpful?

Trade-offs.

Seven calls shaped this flow; the option taken is listed first.

01
Where the photo is shrunk
Chosen:On the device, in a worker
  • Pro:72× fewer bytes on the wire
  • Pro:One plain request is enough
  • Pro:Metadata never leaves the device
Downside we accept:
  • Con:Worker code and a fallback to maintain
Ruled out:Upload the original, resize on the server

42 s on a 1 Mbps uplink; needs resumable uploads; GPS metadata sent to the server

02
Output format
Chosen:WebP, with JPEG when WebP encoding is unavailable
  • Pro:Smaller than JPEG at the same look
  • Pro:Checking blob.type makes the fallback automatic
Downside we accept:
  • Con:Two paths to test
Ruled out:JPEG only

Larger files for the same quality

Ruled out:PNG

Several times larger for photos

03
Upload transport
Chosen:One XMLHttpRequest
  • Pro:Upload progress in every browser
  • Pro:Abortable
  • Pro:A restart costs under a second at 74 KB
Downside we accept:
  • Con:An older API beside fetch elsewhere in the app
Ruled out:fetch() with a streamed body

No progress event; counting needs a stream; duplex half and HTTP/2 or later; not in every browser

Ruled out:Resumable chunks

Extra round trips and server state for a 74 KB body

04
When the avatar changes on screen
Chosen:At once for the owner; for others when the server is ready
  • Pro:Feels instant
  • Pro:Unreviewed photos are never shown to others
Downside we accept:
  • Con:Rollback path for cancel, failure and review
Ruled out:Only after the server says ready

Several seconds of the old face after Save

Ruled out:Everywhere at once, including other users

Skips review

05
Making caches show the new photo
Chosen:New content-addressed URL per photo
  • Pro:Cache for a year as immutable
  • Pro:Nothing to purge
Downside we accept:
  • Con:Every stored copy must be updated, which the Viewer entity does
Ruled out:Same URL with ?v= or a timestamp

Some caches ignore query strings; Copies of the old URL keep the old face

Ruled out:Same URL and a CDN purge

Browsers keep their copy until it expires

06
Where the crop is kept
Chosen:In oriented source pixels
  • Pro:Same result on any screen and pixel ratio
  • Pro:Feeds drawImage directly
Downside we accept:
  • Con:Gestures need converting on every change
Ruled out:In CSS pixels of the viewport

Changes with window size and devicePixelRatio; Easy to export the wrong region

07
How failures are retried
Chosen:Two quiet retries, then Try again, one key
  • Pro:Brief drops heal by themselves
  • Pro:The user decides after that
  • Pro:No duplicates
Downside we accept:
  • Con:At least 5 s of waiting before the user hears about a failure
Ruled out:Retry until it works

Hides a broken network; drains battery

Ruled out:No automatic retry

Every blip becomes an error message

Was this section helpful?
Builds on this
Crop and filter
Read next