Delivery
ExpressCSV validates imported rows in the browser and delivers them through onData as sequential chunks:
- POST each chunk to your backend
- Stage the payload in Postgres
- Write to your business tables only after the last chunk for that delivery arrives
POST each chunk
Each chunk includes:
sessionId,deliveryId,chunkIndex,totalChunks,totalRecords, andrecords(validated rows typed from your schema)- Sequential delivery: chunks arrive in order and the importer waits for
next()before sending the next one (Backpressure)
Put importedUserSchema in a shared package (for example packages/schemas) and import it in both the browser and the API via @expresscsv/schemas.
If chunk POSTs use session cookies instead of a Bearer token, protect them the same way as your other mutating routes (CSRF token, SameSite cookies, or a custom header your frontend sets).
When you test delivery locally with a development secret key, ExpressCSV returns only the first 100 rows and shows a test-mode banner. See Session tokens.
Secure your chunk API
The Hono examples below accept sessionId and deliveryId from the request body. In production, treat those as untrusted until you tie them to your app:
- Require your normal app auth on
POST /your-api/import-users/chunksandPOST /your-api/import-users/abort(session cookie, JWT, API key scoped to the tenant, and so on). - When you mint an ExpressCSV session token (Session tokens), persist a mapping such as
expresscsvSessionId → yourUserId(or embed a signed claim you verify on every chunk POST). - Reject chunks whose
sessionIdis not owned by the authenticated caller (403).
Use the same importNamespace in useExpressCSV() and when you create the session token. A mismatch causes confusing session behavior.
Recommended pattern
How you persist imported rows is up to you. A recommended pattern:
- Stage each chunk in a dedicated table (not your business tables).
- Upsert with
onConflictDoUpdateon(sessionId, deliveryId, chunkIndex)so chunk retries are safe. - Track delivery status so promoting the last chunk twice (retry) does not insert duplicate business rows.
- Promote inside a transaction: read staged chunks in order, insert into your business table, mark the delivery promoted.
- Delete staged rows on abort or error when the browser tells you delivery failed or was canceled.
Schema
Keep staging data separate from live tables. A small import_delivery row per deliveryId makes promotion idempotent when the last chunk POST retries.
sessionId: groups one import run in the browser.deliveryId: one finish attempt. A new id is used if the user presses Finish again after a failure.chunkIndex/totalChunks: position and size of this delivery (each chunk carries both).
If a new deliveryId appears after a retry, older staging rows for the same sessionId may still exist. Delete them in abort handlers or TTL them.
Promote on the last chunk POST (server-authoritative):
onCompleteis optional and only for client-side UX (toast, analytics, redirect)- Do not promote from
onComplete: it shares the same best-effort lifecycle asonErrorandonCancel(Errors)
Checks on every chunk POST
Each chunk body includes sessionId, deliveryId, chunkIndex, and totalChunks (plus optional totalRecords). The importer sends them in order, but your API still needs to enforce delivery state:
- Networks retry POSTs
- Users can cancel mid-flight
- A buggy client could send the wrong index or an early "last" chunk
Run these checks in your chunk handler before you write to staging (see assertChunkMetadata below). They are separate from validating row shape in records (Validate the chunk body).
| Check | Why |
|---|---|
chunkIndex < totalChunks | Reject out-of-range indices (400) |
First chunk for a deliveryId sets totalChunks; later chunks for that id must send the same value | Stops a client from shrinking totalChunks to promote early |
Reject chunk POSTs when import_deliveries.status is promoted or aborted | 409 Conflict; retries after success or cleanup should not redo work |
Store totalRecords on the delivery row when the chunk includes it | Logging, progress UI, and sanity checks after promotion |
Validate the chunk body
Validate the POST body on the server even though the importer already validated rows in the browser:
- Networks, proxies, and old clients can send malformed JSON
- Re-parse with your schema when promoting staged rows to business tables
Stage
Upsert each chunk into staging and record delivery metadata. Reject chunks when the delivery is already promoted or aborted.
Promote
When the last chunk POST succeeds, copy staged rows into your business table in one transaction. Retries on that last POST must not insert twice.
Abort
Wire onError and onCancel to POST /your-api/import-users/abort:
onError: receives{ sessionId, deliveryId }when delivery started; usually includesdeliveryIdonCancel: session-scoped and may run before any chunk lands; may send onlysessionId
Mark the delivery aborted and delete staging rows for the scope you receive.
Hono routes
Wire the handlers to /your-api/import-users/chunks and /your-api/import-users/abort. Return non-2xx from the chunk route if staging fails so the importer stops and runs onError.
If imported rows trigger emails, billing, webhooks, search indexing, inventory updates, or other user-visible automation, do not run those from chunk writes alone. Run them only after rows are in your business tables (same transaction as promotion, or an outbox keyed to deliveryId at the promoted transition).
- Log
sessionId,deliveryId,chunkIndex, andtotalChunkson every chunk POST; time staging and promotion - Track chunk 5xx rate, promotion failures, and deliveries stuck in
staging - Set a request body size limit on the chunk route that matches your
chunkSizeand reverse-proxy limits
Backpressure
Chunks are sequential:
- ExpressCSV waits for
next()before sending the next chunk - Call
next()only after the API has committed this chunk to your import chunk staging table
Errors
- If
onDatathrows or rejects beforenext(), delivery stops andonError(error, { sessionId, deliveryId })runs. UsedeliveryIdto fail that attempt or delete rows from your import chunk staging table. onErrorcan arrive with only{ sessionId }before delivery starts. Make abort tolerate a missingdeliveryId.onCancelis session-scoped; the user may cancel before any delivery—clean up with{ sessionId }.
onCancel, onError, and onComplete may not run if the tab closes, navigates away, or the iframe is removed. Treat your import chunk staging table and delivery status on the server as the source of truth.