Safari, the Fetch API, and 30X redirects

Or, what I “lurmed” today

Mr. Jackdaw
5 min readJun 25, 2017

It all started with a frozen modal.
Users saving changes to some of their data in a web application I had built were stuck behind an unresponsive Saving edits… modal. More bizarrely:

  • This was only happening in Safari
  • Subsequent logins in a different browser showed that user changes had been persisted, in spite of the app’s unresponsive state.

What?

So I did what any highly-skilled JS developer would do: throw a bunch of alert() calls everywhere.

Just kidding. But I did start logging my requests.

TL;DR:

  • Create a fetch Request object with your URL and request options
  • Set the request’s Request.redirect property to “manual”, or browsers will default to “follow”
  • Pass your custom Request object into your fetch.
  • This will prevent Safari from being a real bastard about automatically following things it shouldn’t follow.

What are you doing, there?

My application’s “API” (or remote-data-request) layer depends on two critical files.

APIRoutes is a key-value store that defines server endpoints:

APIRoutes = {
"routeName": {
// (e.g. default headers, request methods, etc)
}
}

Next, a helper APIConfig class that constructs a single fetch() request using a single route from the APIRoutes object:

class APIConfig {    request(endpoint) {        let configuredRoute = APIRoutes[endpoint];        configuredRoute.with = (params) => {            // Configure URL, attach headers, authorization, e.t.c
let url = configuredRoute.url(params)'
// Configure headers, method, etc
let requestOptions = {};
// Note that Fetch won't reject a promise unless there
// is a network failure, so do some gymnastics around
// special server response codes here (e.g. auth fail)
return fetch(url, requestOptions)
.then( /* handle Promise.resolve */ )
.catch( /* handle Promise.reject */ )
} return configuredRoute;
}
}

This config would be instantiated once on a service class, which could use it thus:

class MyServiceClass {    constructor(APIConfig) {
this.config = new APIConfig()
}
... getSomethingFromServer(params) {
return this.config.request("routeName").with(params);
}
}

This structure makes it really easy to add a new endpoint. Throw in a route, add the necessary method for hitting that route to MyServiceClass, and we’re good to go. It also abstracts away our data fetching, so that — for example — I could swap out fetch() calls with any other Promise-generating library, and the app would be unaffected.

Most importantly, however, this meant I only needed one file to debug my Safari nightmare — because only (APIConfig) was responsible for creating every single promise generated and consumed by the web application. And then something interesting happened.

Safari? More like Sa-FURY, amirite?

It seemed so simple. The request preceding the failure was a PUT request, and there were very few of those in the app. So I started checking response codes from PUT requests… and discovered that only Safari was getting a 401 response (authentication failure) from our server. Which made no sense, because almost every other request in the application requires authentication, and this was the only failing request — occurring deep in an “authenticated user” state.

Worse, in Safari’s network timeline, the actual PUT request just seemed to never resolve, whereas in Chrome it showed resolution.

Then it struck me: our servers responded to this particular request with a redirect status code (e.g. 301, 302, 303…). So this had to be related.

Enter The Google

Hours of aggressive googling only seemed to indicate that there had been …quirks… around Safari’s interaction with redirect responses. However, all discussions around this issue 1) Did not reveal anything like my issue, and 2) Were often years old, referring to long-obsolete versions of the Safari browser.

Eventually, I stumbled across the news that Safari’s Inspector had removed the ability to see network redirects. A StackOverflow user even created a bug report for it in 2015**. For me, this meant two things:

  1. Safari may have received a redirect response, but I didn’t have the security clearance to view that information, and;
  2. Safari was doing something unexpected behind the scenes with this particular request.

The logical assumption relating to 2 was that Safari was automatically following the redirect response, where other browsers were handling it more gracefully.

But pourquoi?

The Request() object

Turns out that when you make a call like fetch(myUrl, myRequestOptions) , the fetch API creates a Request() object behind the scenes for your request.

The Request object has a redirect property, which can hold one of three string values: follow, error, and manual. These values tell the browser what to do when a remote server responds to a request with a redirect. If this value is not specified, a Request will default to follow.

Which was all well and dandy until some browsers decided to handle those redirects differently.

My Solution

Once again celebrating my relative ease-of-debugging, I jumped right into my APIConfig file and made a tiny change:

// APIConfig.js... // Configure URL, attach headers, authorization, e.t.c
let url = configuredRoute.url(params)'
// Configure headers, method, etc
let requestOptions = {};
// This is where I became a tricksy little hobbitsesrequestOptions.redirect = "manual"
let request = new Request(url, requestOptions)
// Use the custom request with the returned fetch() promise
return fetch(request).then( ... )

Now, any 30x responses would have a type property that resolved to opaqueredirect, which I could check for, and react to appropriately.

A subsequent round of logging the results showed more predictable results. (Oh, Safari still showed unique differences in the response data, but these differences didn’t interfere with app functionality).

And voila! No more tears.

Conclusion

I celebrate JS challenges that make me a better developer; particularly edge-case problems where I cannot simply “google” or SO my way to victory. And in the spirit of becoming better, I have ultimately written this in the event that it helps someone . Or in the event that someone better than me, who read this far and is genuinely alarmed by my thought-process, can step in and show me a better way to Javascript.

For the record, this is technically the very first time I have done something like this. The internet and development community helped bring me where I am today; it is, perhaps, time to start giving back.

As always I welcome questions, comments, and discussions, as well as any and all such-as-like. Cheers!

Updates

**Update (12/2018): Thank you all for the claps; I’ve been pleasantly surprised by the reaction to this article. If you’re interested in employing a similar request architecture as described below, I made a plain JS library for it here.

**Update (04/2019): there has been some activity in that bug report thread since I wrote this article, though I am neither following nor taking credit for it.)

--

--