Skip to content

perf(sdk): every bodyless GET carries Content-Type, forcing a CORS preflight on cross-origin reads #472

Description

@EricAndrechek

Area: sdk — performance / DX · found while migrating a browser app onto the SDK

Expected: a bodyless GET from a browser is a CORS "simple request" and goes straight out — no preflight.

Actual: every request carries a Content-Type, including reads with no body. clients/ts/src/http.ts:65:

const headers: Record<string, string> = {
  "Content-Type": opts.contentType ?? "application/json",
  Accept: "application/json",
};

There is no method or body check — the header is set for schema.get(), pipes.list(), policy.get(), a plain .select(), everything. application/json is not one of the three CORS-safelisted Content-Type values (application/x-www-form-urlencoded, multipart/form-data, text/plain), so a cross-origin read costs an OPTIONS round trip before the GET that actually fetches data. (Accept: application/json is fine — that one is safelisted.)

Why it matters: the standard browser deployment is the SDK on one origin and WaveHouse on another (app.example.comwh.example.com, or any subdomain split), and that is exactly the shape where this doubles the request count for reads. Access-Control-Max-Age bounds the cost but doesn't remove it — browsers cap the value (Chrome at 2h, Firefox at 24h), it's per method+URL, and the first load of every screen — the one users actually feel — pays it regardless.

It's also just wrong on HTTP grounds: a Content-Type describes a representation in the message body, so on a request with no body there is nothing for it to describe.

No client-side workaround. options.headers is applied underneath the SDK's own headers (deliberately, and documented — auth owns Authorization, and a request's Content-Type/Accept can't be displaced), so a consumer cannot unset it. options.fetch could strip it, but replacing the entire HTTP implementation to remove one header is not a fix anyone should have to reach for.

Scope: set Content-Type only when the request actually has a body — i.e. when opts.body or opts.rawBody is defined. contentType overrides keep working for the bodies that need them (NDJSON ingest). Accept stays unconditionally, since it's safelisted and costs nothing.

Worth a test asserting a bodyless GET carries no Content-Type, since the regression is invisible in any same-origin or server-side test — it only shows up as a preflight in a browser against a second origin.


Found while migrating a first-party web console onto the SDK, 2026-08-13.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/sdkTypeScript SDK (clients/ts/)enhancementNew feature or request

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions