Cross-Origin Resource Sharing trips up even experienced developers when chunked uploads enter the picture. A standard form-based upload might never trigger a preflight request, but the moment you add custom headers, send non-standard content types, or use PUT/PATCH methods, the browser's CORS machinery activates—and without the right server configuration, every chunk fails silently. This guide explains why Resumable.js uploads trigger CORS, how preflight requests work, the exact server headers you need, credential handling, debugging techniques, and proxy alternatives. For more Resumable.js configuration patterns, see the guides hub.
What CORS Is and Why Uploads Trigger It
Browsers enforce the same-origin policy: JavaScript on app.example.com cannot make requests to upload.example.com or api.example.com:8080 without explicit permission from the target server. The origin is defined by scheme, host, and port—change any one of them and you're cross-origin.
Resumable.js uploads trigger CORS whenever the upload endpoint lives on a different origin than the page. That includes:
- Different subdomains (
app.example.com→upload.example.com) - Different ports (
localhost:3000→localhost:8080) - Different schemes (
http→https) - Entirely different domains (
yourapp.com→storage-api.provider.com)
Even if you control both domains, the browser doesn't know that. It enforces CORS regardless.
The formal specification for Cross-Origin Resource Sharing is maintained by the W3C at https://www.w3.org/TR/cors/, and it's worth reading if you want to understand the full negotiation model beyond what this guide covers.
Preflight Requests for Chunked Uploads
Not every cross-origin request triggers a preflight. Simple requests—GET with no custom headers, POST with application/x-www-form-urlencoded or multipart/form-data—skip the preflight and go straight through. But Resumable.js typically triggers preflight because:
- GET requests with query parameters for
testChunksare usually fine (simple requests). - POST requests with
multipart/form-datafor chunk upload can be simple if you don't add custom headers. - Custom headers like
Authorization,X-CSRF-Token, or anything application-specific immediately make it a non-simple request.
A preflight is an OPTIONS request the browser sends before the actual request. It asks the server: "Would you accept a POST from this origin, with these headers?" The server responds with CORS headers indicating what's allowed. Only then does the browser send the actual chunk upload.
This means every chunk upload might generate two HTTP requests: one OPTIONS preflight and one POST with the actual data. For a file with 100 chunks, that's potentially 200 requests. Preflight caching (via Access-Control-Max-Age) mitigates this, but you need to set it up.
Required Server Headers
Your upload endpoint must respond to both OPTIONS preflight requests and actual upload requests (GET/POST) with the correct CORS headers. Here's the minimum set:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
Access-Control-Max-Age: 86400
Breaking these down
Access-Control-Allow-Origin — The origin(s) allowed to make requests. Use the exact origin of your frontend (https://app.example.com), or * for public APIs. You cannot use * if you also need credentials (cookies, auth headers).
Access-Control-Allow-Methods — Which HTTP methods the server accepts cross-origin. At minimum, Resumable.js needs GET (for testChunks) and POST (for chunk upload). Include OPTIONS so the preflight itself succeeds.
Access-Control-Allow-Headers — Which request headers the client is allowed to send. Content-Type is needed for multipart uploads. Add any custom headers your application sends (auth tokens, CSRF tokens, request IDs).
Access-Control-Max-Age — How long (in seconds) the browser should cache the preflight response. 86400 (24 hours) is a reasonable value. Without this, the browser sends a preflight before every single chunk—doubling your request count.
Express.js example
const cors = require('cors');
app.use('/api/upload', cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
maxAge: 86400,
credentials: true,
}));
Nginx example
location /api/upload {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Credentials' 'true';
# ... proxy_pass or upload handling
}
Handling Credentials
If your upload endpoint requires cookies or HTTP authentication, you need two things:
-
Server: Include
Access-Control-Allow-Credentials: truein the response, andAccess-Control-Allow-Originmust be an exact origin (not*). -
Client: Set
withCredentialsin Resumable.js:
const r = new Resumable({
target: 'https://upload.example.com/api/upload',
withCredentials: true,
});
This tells the browser to include cookies and auth headers in cross-origin requests. Without it, your session cookie won't be sent and authenticated endpoints will return 401 on every chunk.
A common headache: you test with Access-Control-Allow-Origin: * during development, then add withCredentials: true for auth, and everything breaks. The wildcard origin and credentials are mutually exclusive by specification. Switch to an explicit origin and the problem vanishes.
Common CORS Errors and What They Mean
"No 'Access-Control-Allow-Origin' header is present on the requested resource." — Your server isn't returning CORS headers at all. Check that your CORS middleware runs on the upload route, including for OPTIONS requests.
"The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'." — You have withCredentials: true on the client but * as the allowed origin on the server. Use a specific origin.
"Method POST is not allowed by Access-Control-Allow-Methods." — Your OPTIONS response doesn't list POST in allowed methods. Update the header.
"Request header field Authorization is not allowed by Access-Control-Allow-Headers." — Your server's Access-Control-Allow-Headers doesn't include Authorization. Add it.
Preflight response has no HTTP ok status. — Your server's OPTIONS handler is returning 404 or 500 instead of 200/204. Make sure your framework handles OPTIONS requests on the upload route.
Debugging CORS Issues
The browser's Network tab is your best friend. Filter for the upload endpoint URL and look for:
- The OPTIONS request. Does it return 200 or 204? Do the response headers include all necessary CORS headers?
- The actual POST/GET request. If the OPTIONS failed, this won't even appear—the browser blocks it before sending.
// Temporary debugging: log all Resumable.js errors
r.on('error', (message, file) => {
console.error('Upload error:', message);
});
If you see errors in the console but nothing in the Network tab, the browser blocked the request at the preflight stage. The server never saw it.
Another useful trick: test your endpoint directly with curl to verify the headers outside the browser's CORS enforcement:
curl -X OPTIONS https://upload.example.com/api/upload \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-v
Check the response headers. If they're correct in curl but not working in the browser, something in your infrastructure (CDN, load balancer, WAF) is stripping or overriding them.
The Proxy Alternative
Sometimes CORS configuration isn't practical—you don't control the upload server, or the infrastructure makes header management painful. In those cases, proxy the upload through your own origin:
// Frontend: upload to same origin
const r = new Resumable({
target: '/api/upload-proxy',
});
// Backend (same origin): forward to actual upload service
app.post('/api/upload-proxy', (req, res) => {
// Forward the chunk to the real upload endpoint
// No CORS involved—server-to-server requests aren't subject to it
});
This eliminates CORS entirely because the browser only communicates with its own origin. The trade-off is that all upload traffic flows through your application server, adding latency and load. For small files or low-traffic applications, that's fine. For high-throughput upload systems, fixing CORS properly is worth the effort.
CORS feels like an obstacle, but it's a security mechanism protecting your users. Work with it, configure it correctly once, and it becomes invisible. Skip the configuration, and you'll spend more time debugging failed uploads than you would have spent setting up the headers.
