Using Fetch to Read JSON with Text Fallback

Sometimes it's helpful to be able to read data that could be either JSON or text and parse it as such.

For example, all data in LocalStorage is saved as a string. This means saving the boolean true will be saved as the string "true". A common pattern for reading from LocalStorage is to use JSON.parse() to parse the data on read. Since the parse could fail for non-JSON like data, we wrap the parse in a try/catch and fallback to the raw text value.

let value = localStorage.getItem(key);
try {
  value = JSON.parse(value);
}
catch(e) {}

This pattern can also be helpful for when you need to make an HTTP request for data that could be JSON or text. But when trying to do this pattern using fetch(), I ran into some problems.

Typically when you read JSON from a fetch call, you use response.json() to return a promise which resolves with the parsed JSON.

fetch('/file.json')
  .then(response => response.json())
  .then(data => {
    // data is now parsed JSON
  });

I figured that if the response.json() promise failed, I could try to parse the response as text using response.text(). However, doing so resulted in a TypeError: body stream already read error.

fetch('/file')
  .then(response => 
    response.json().catch(() => response.text())
    // TypeError: body stream already read
  )

It turns out you can only read the response body once. Not knowing what to do, I turned to the internet. Thankfully, Jake Archibald wrote an article where he mentioned cloning the stream to be able to read it twice.

By using response.clone() we can create a copy of the response to parse as JSON, and if that fails we parse the original response as text.

fetch('/file')
  .then(response => 
    response.clone().json().catch(() => response.text())
  ).then(data => {
    // data is now parsed JSON or raw text
  });