Skip to content

Web - Silent upload loss when picked file changes on disk before request queue is persisted #97050

Description

@melvin-bot

Background: New Expensify is offline-first: every API write on web goes into a persisted request queue (the networkRequestQueue Onyx key) stored in IndexedDB, so requests survive reloads and offline periods. When a user attaches a file — a chat attachment, a replaced receipt, or a SmartScan/expense receipt — the picked File object is inlined into that queued request and written to IndexedDB along with it, and the whole queue is rewritten on every new request and every Pusher ping. In Chromium, a File picked from disk is a lazy reference to the OS file: the browser records size and mtime at pick time, and only reads the actual bytes at the first write to IndexedDB. Once that first write succeeds, the bytes live in the browser's own blob store.

Problem: When a web user has attached a file and the file on disk then changes before the queued request is first persisted — whether by user action, a download still completing, or background software touching the file — the write fails with DataError: Failed to write blobs (InvalidBlob/IOError), the upload is silently lost, and the dead file poisons every subsequent queue write. ~46,000 such errors from 13+ users over 3 days in production, thousands per affected user.

Proposed solution: Copy the picked file's bytes into memory at validation time, so the persisted request never references the OS file at all. In src/libs/validateAttachmentFile.ts — the single choke point every attachment and receipt flow passes through via useFilesValidation:

  1. Snapshot the file into a memory-backed File via a platform-split module (src/libs/snapshotPickedFile/index.ts + index.native.ts): the web variant copies the bytes (new File([await file.arrayBuffer()], cleanName, {type, lastModified})), the native variant keeps the existing rename-only behavior. A memory-backed File cannot be invalidated by later disk changes, so the first IndexedDB persist always has real bytes — the write can't fail this way, the queue can't be poisoned, and the upload isn't lost.
  2. If the read already fails at validation (file already gone or modified), return FILE_VALIDATION_ERRORS.FILE_INVALID so the user sees an immediate error and can re-pick, instead of a silent post-send failure.
  3. Native cannot hit this failure: file objects there are plain {uri} (never File), storage is SQLite with no IndexedDB blob path, and production logs show this error class exclusively from web user agents.
  4. Memory cost is bounded and transient: receipts capped at 10MB, attachments at 24MB, held only while the request is in flight.

Complementary storage-layer fix: retrying this error is provably futile (every prod failure runs retries 0/5 → 5/5 on the same dead bytes), so Expensify/react-native-onyx#814 classifies Failed to write blobs as INVALID_DATA (never retried), tested via #96957. Independent of the main fix.

The root cause is verified, not theorized: production logs show every failure at first persist (retryAttempt: 0/5) seconds after a file-bearing API write, and the exact error reproduces locally by deleting or modifying the picked file while the preview is open.

Reported in Slack.

Issue OwnerCurrent Issue Owner: @elirangoshen

Metadata

Metadata

Labels

Type

No type

Projects

Status
LOW

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions