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.
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
Functional requirements
Non-functional requirements
| No. | Area | Requirement | Target / measure |
|---|---|---|---|
| 01 | Responsiveness | The 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. |
| 02 | Upload size and time | Send only what the largest avatar needs. | Upload body under 150 KBp95 under 3 s on a 1 Mbps uplink |
| 03 | Memory | One large photo must not crash a low-memory tab. | Peak decoded pixels under 100 MBEvery object URL revoked |
| 04 | Reliability | A retry never creates a second avatar or re-runs the crop. | Same idempotency key on every retry0 duplicate avatars |
| 05 | Accessibility | The whole flow works by keyboard and screen reader. | WCAG 2.2 AAOne announcement per outcome |
| 06 | Privacy | GPS 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
- 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é
- Decoded full photo in memory4032 × 3024 × 4 bytes (RGBA)≈ 48.8 MBfrom Original pixels · Why the full bitmap lives briefly, in a worker.
- Bytes saved5.3 MB ÷ 74 KB≈ 72× smallerfrom Original file and Output size
- Upload time, original5.3 MB × 8 ÷ 1 Mbps≈ 42 sfrom Original file and Slow uplink
- Upload time, compressed74 KB × 8 ÷ 1 Mbps≈ 0.6 sfrom Output size and Slow uplink
- 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
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
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
- Checks before decode2AvatarUploader
- Crop in image pixels3CropDialog
- Zoom slider3CropDialog
- Resize and encode in a worker4ImageWorker
- 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.
- Avatar with pending ring7Avatar
- Change photo opens the dialog1EditProfilePage
- 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
Failed keeps the blob and the key, so Try again goes straight back to uploading.
| From → To | Event | Guard | Action |
|---|---|---|---|
| Idle → Checking | file chosen | ||
| Checking → Rejected | check fails | show reason | |
| Checking → Framing | checks pass | object URL | |
| Rejected → Checking | file chosen | ||
| Framing → Compressing | Save | post file + crop to worker | |
| Compressing → Rejected | decode fails | revoke URL | |
| Compressing → Uploading | blob ready | new key · send | |
| Uploading → Waiting to retry | network error or 5xx | attempt < 3 | |
| Waiting to retry → Uploading | timer | same blob + key | |
| Uploading → Failed | network error or 5xx | attempt = 3 | |
| Failed → Uploading | Try again | same blob + key | |
| Uploading → Processing | 202 processing | start polling | |
| Processing → Done | status ready | swap URL · revoke blob | |
| Processing → Declined | status rejected | roll back avatar | |
| Processing → Failed | no verdict in 30 s | ||
| Compressing → Cancelled | Cancel | abort worker job | |
| Uploading → Cancelled | Cancel | xhr.abort() | |
| Waiting to retry → Cancelled | Cancel | clear 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
- Kavya → CropDialog: zoom 1.6x · drag · Save
- CropDialog → AvatarUploader (reply): crop { x 567, y 610, size 1890 }
- AvatarUploader → ImageWorker: postMessage { file, crop, size 640 }
- Note over ImageWorker: decode · orient · cut · scale · encode WebP
- ImageWorker → AvatarUploader (reply): { blob 74 KB, type image/webp }
- AvatarUploader → ViewerStore: avatarPending(blob URL)
- AvatarUploader → UploadService: upload(blob, key 7d2e…)
- UploadService → Media API: POST /api/me/avatar (attempt 1)
- Note over UploadService: network error at 62% · wait 1 s
- UploadService → Media API: POST again, same Idempotency-Key
- Media API → UploadService (reply): 202 { upload_id, status: processing }
- UploadService → Media API: GET /api/me/avatar/uploads/{id}
- Media API → UploadService (reply): 200 { status: ready, avatar }
- UploadService → AvatarUploader (reply): Ready(avatar)
- AvatarUploader → ViewerStore: avatarConfirmed(avatar)
- Note over ViewerStore: every Avatar re-renders · blob URL revoked
| Code | Outcome | Kind | What happens | Reacts |
|---|---|---|---|---|
| PRE | too large · wrong type · too many pixels | error | Reason shown; nothing decoded. | 2AvatarUploader |
| DECODE | unreadable photo | error | Ask for a JPEG or PNG. | 4ImageWorker |
| NET | dropped or stalled | error | Two quiet retries, then Try again. | 5UploadService |
| 202 | processing | challenge | Finishing up, polling every 1.5 s. | 5UploadService |
| 200 | ready | success | New URL in every avatar. | 6ViewerStore |
| 200 | status: rejected (moderation) | error | Old avatar returns. | 6ViewerStore |
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.
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) | Zoom | Square kept | Scale to 640 px | Note |
|---|---|---|---|---|
| 3024 × 4032 phone photo | 1x | 3024 px | 0.21 | Whole width; a big downscale, so smoothing quality matters |
| 3024 × 4032 phone photo | 1.6x | 1890 px | 0.34 | Kavya's framing |
| 3024 × 4032 phone photo | 4x | 756 px | 0.85 | The slider's top end |
| 900 × 1200 older photo | 2.81x | 320 px | 2.0 | Zoom 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' });
}
};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
Server to client APIs
Stores a new avatar and starts processing. The Idempotency-Key makes a resend return the same upload instead of a second one.
The upload's processing status. Polled every poll_after_ms while the dialog waits, for at most 30 s.
Removes the current photo. Sent only after the undo toast closes, so Undo needs no request.
Error cases
Every error has one shape. UploadService maps it to a typed result; the uploader decides what the user sees.
| HTTP | Type | Body code | Client behaviour |
|---|---|---|---|
| 400 | error | missing_idempotency_key | A client bug; report it. |
| 401 | challenge | not_authenticated | Session expired. Keep the blob, open the login modal, resend with the same key. |
| 403 | retry | csrf_failed | Refresh the token once and resend once. |
| 409 | retry | request_in_progress | The first request with this key is still running. Wait poll_after_ms and resend with the same key. |
| 413 | error | too_large | Unlikely at 640 px. Re-encode once at quality 0.7, then give up. |
| 415 | error | unsupported_type | The server could not read the bytes. Ask for another photo. |
| 422 | error | idempotency_key_reused | Same key, different bytes, so a client bug. Only a new crop gets a new key. |
| 429 | error | rate_limited+ retry_after_s | Too many changes. Row says “Try again in 2 min” with a countdown. |
| 5xx | network | 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.
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.
- Prop
- maxBytes
- Default
- 25_000_000
- Why
- The input limit, checked against File.size before anything is read.
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.
| Rank | Area | Impact if it fails | Users affected | How often it matters | Score | Decision |
|---|---|---|---|---|---|---|
| 1 | Main-thread responsiveness | 3 of 3 | 3 of 3 | 3 of 3 | 9 | Deep diveEvery upload decodes 10+ megapixels; on the main thread the dialog locks up. |
| 2 | Checks before decode and memory | 3 of 3 | 3 of 3 | 2 of 3 | 8 | Deep diveOne oversized or hostile file can crash the tab. |
| 3 | Network resilience | 2 of 3 | 3 of 3 | 3 of 3 | 8 | Deep diveDrops and stalls are routine on mobile. |
| 4 | Avatar swap and caching | 2 of 3 | 3 of 3 | 3 of 3 | 8 | Deep diveA stale cached face reads as failure. |
| 5 | Accessibility | 3 of 3 | 2 of 3 | 3 of 3 | 8 | Deep diveA mouse-only crop or silent progress blocks some users outright. |
| 6 | Security and privacy | 3 of 3 | 1 of 3 | 1 of 3 | 5 | Handled brieflyThe server re-validates; the client mostly must not leak metadata. |
| 7 | Internationalisation | 1 of 3 | 2 of 3 | 1 of 3 | 4 | Handled brieflyA few strings and number formats. |
| 8 | Offline | 1 of 3 | 1 of 3 | 1 of 3 | 3 | Handled brieflyFailing clearly is enough. |
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
- SOI + marker, 3 bytes, Signature checked, value FF D8 FF
- APP1 (EXIF) usually next, 9 bytes, Not read for the type
- signature, 8 bytes, Signature checked, value 89 50 4E 47 0D 0A 1A 0A
- IHDR length, 4 bytes, Not read for the type
- RIFF, 4 bytes, Signature checked, value 52 49 46 46
- size, 4 bytes, Length field, skipped
- WEBP, 4 bytes, Format brand checked, value 57 45 42 50
- box size, 4 bytes, Length field, skipped
- ftyp, 4 bytes, Signature checked, value 66 74 79 70
- heic · heix · mif1, 4 bytes, Format brand checked
- II*, 4 bytes, Not an accepted signature, value 49 49 2A 00
- 8 bytes, Not read for the type
- Signature checked
- Format brand checked
- Length field, skipped
- Not read for the type
- Not an accepted signature
As it starts. 3 steps follow.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Size first | A 31 MB file is read just to be refused. | File.size needs no read. Over 25 MB is refused at once. | 2AvatarUploader |
| 02 | Type from bytes | A 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 |
| 03 | Pixel cap before decode | A 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 |
| 04 | Preview without copies | A 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 |
| 05 | Revoke every object URL | Each 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 |
| 06 | Close bitmaps at once | A 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 |
| 07 | Undecodable formats | An 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 |
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.
- Save · Main thread → ImageWorker, arriving Save+5 ms: file + crop
- Save+5–Save+245 ms · ImageWorker · decode + orient
- Save+50 ms · all lanes · 50 ms long-task line (deadline)
- Save+245–Save+315 ms · ImageWorker · cut + scale
- Save+315–Save+405 ms · ImageWorker · encode WebP
- Save+405 ms · ImageWorker → Main thread, arriving Save+410 ms: blob
- Save+410 ms · Main thread · pending avatar
- Save+412–Save+1,012 ms · Upload request · 74 KB at 1 Mbps
- Save+1,012–Save+2,100 ms · Media API · sizes + review
- Save+1,012 ms · Media API · 202 processing (tick)
- Save+2,512–Save+2,545 ms · Main thread · preload + swap
- Save+2,512 ms · Main thread · poll: ready (ok, ok)
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Decode in the worker, not on the page | Decoding 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 |
| 02 | One full-size bitmap, briefly | Copying 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 |
| 03 | Preview stays cheap | Redrawing 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 |
| 04 | Feature-detect, then fall back | Without 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 |
| 05 | Downscale quality | A 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 |
| 06 | Worker lifetime | Start-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 |
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
Data
| Uplink (Mbps) | Original 5.3 MB (s) | Avatar 74 KB (s) |
|---|---|---|
| 0.5 | 84.8 | 1.18 |
| 1 | 42.4 | 0.59 |
| 2 | 21.2 | 0.3 |
| 5 | 8.48 | 0.12 |
| 10 | 4.24 | 0.06 |
| 20 | 2.12 | 0.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);
});
}| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | Honest progress | The 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 |
| 02 | Stall, not just failure | On 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 |
| 03 | Retry the same thing | A 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 |
| 04 | Back off, then hand over | Instant 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 |
| 05 | Wait for the network | Retrying offline burns attempts. | If navigator.onLine is false, hold the next attempt until the online event; the row says Waiting for connection. | 5UploadService |
| 06 | One cancel for everything | Cancel 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 |
| 07 | Leaving mid-upload | Closing 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”.
- One AbortController5UploadService
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. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | One entity, many views | The 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 |
| 02 | A new URL, not a query string | With ?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 |
| 03 | Optimistic, but only for the owner | Waiting 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 |
| 04 | No flash on swap | Swapping 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 |
| 05 | Rollback | A cancelled, failed or declined upload leaves the new face on screen. | The pending AvatarRef keeps previous; one write restores every view. | 6ViewerStore |
| 06 | Other tabs | A 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 |
| 07 | Broken image fallback | An 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 |
Deep dive: accessibility
A circle crop is pointer-first. Each part needs a keyboard and screen reader equivalent.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | The picker is a real input | A 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 |
| 02 | Zoom as a slider | Screen 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 |
| 03 | Panning without a pointer | Framing 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 |
| 04 | Progress without chatter | Announcing 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 |
| 05 | Errors where focus is | A 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 |
| 06 | Focus in and out of the dialog | Focus 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 |
| 07 | Reduced motion | Zoom 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
| Area | What we do | Owner |
|---|---|---|
| Privacy | Re-encoding from a canvas drops the EXIF block, so GPS position and device details never leave the device. The server strips metadata anyway. | ImageWorker |
| Security | CSRF 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 |
| Internationalisation | Status words come from the labels prop; numbers use Intl.NumberFormat; the dialog mirrors under dir="rtl". | AvatarUploader |
| Offline | No queue across reloads. Offline at Save waits for the online event. | UploadService |
| Telemetry | Refusals by reason, worker vs fallback, encode time, output size, attempts, outcome. Never file names or bytes. | AvatarUploader |
Trade-offs.
Seven calls shaped this flow; the option taken is listed first.
- Pro:72× fewer bytes on the wire
- Pro:One plain request is enough
- Pro:Metadata never leaves the device
- Con:Worker code and a fallback to maintain
42 s on a 1 Mbps uplink; needs resumable uploads; GPS metadata sent to the server
- Pro:Smaller than JPEG at the same look
- Pro:Checking blob.type makes the fallback automatic
- Con:Two paths to test
Larger files for the same quality
Several times larger for photos
- Pro:Upload progress in every browser
- Pro:Abortable
- Pro:A restart costs under a second at 74 KB
- Con:An older API beside fetch elsewhere in the app
No progress event; counting needs a stream; duplex half and HTTP/2 or later; not in every browser
Extra round trips and server state for a 74 KB body
- Pro:Feels instant
- Pro:Unreviewed photos are never shown to others
- Con:Rollback path for cancel, failure and review
Several seconds of the old face after Save
Skips review
- Pro:Cache for a year as immutable
- Pro:Nothing to purge
- Con:Every stored copy must be updated, which the Viewer entity does
Some caches ignore query strings; Copies of the old URL keep the old face
Browsers keep their copy until it expires
- Pro:Same result on any screen and pixel ratio
- Pro:Feeds drawImage directly
- Con:Gestures need converting on every change
Changes with window size and devicePixelRatio; Easy to export the wrong region
- Pro:Brief drops heal by themselves
- Pro:The user decides after that
- Pro:No duplicates
- Con:At least 5 s of waiting before the user hears about a failure
Hides a broken network; drains battery
Every blip becomes an error message