JSONP has always been one of the most poorly explained concepts in all of web development. This is likely due to its confusing name and overall sketchy background. Prior to the adoption of CORS, JSONP was the only option to get a JSON response from a server of a different origin.

After sending a request to a server of a different origin that doesn’t support CORS, the following error would be thrown:

The console error
The console error

Upon seeing this, many people would Google it just to find out that JSONP would be needed to bypass the same-origin policy. Then jQuery, ubiquitous back in the day, would swoop in with its convenient JSONP implementation baked right into the core library so that we could get it working by switching just one parameter, without ever knowing what changed completely was the underlying mechanism of sending the request.

$.ajax({
  url: 'http://twitter.com/status/user_timeline/padraicb.json?count=10',
  dataType: 'jsonp',
  success: function onSuccess() {},
});

In order to understand what went on behind the scenes, let’s take a look what JSONP really is.

What’s JSONP?

JSON with padding — or JSONP for short — is a technique that allows developers to bypass (using the <script> element’s nature) the same-origin policy that is enforced by browsers. The policy disallows reading any responses sent by websites whose origins are different from the one currently used. Incidentally, the policy allows sending a request, but not reading one.

A website’s origin consists of three parts. First, there’s the URI Scheme (i.e., https://), then the host name (i.e., logrocket.com), and, finally, the port (i.e., 443). Websites like http://logrocket.com and https://logrocket.com have two different origins due to the URI Scheme difference.

If you wish to learn more about this policy, look no further.

How does it work?

Let’s assume that we are on localhost:8000 and we send a request to a server providing a JSON API.

https://www.server.com/api/person/1

The response may look like this:

{
  "firstName": "Maciej",
  "lastName": "Cieslar"
}

But due to the aforementioned policy, the request would be blocked because the origins of the website and the server differ.

Instead of sending the request ourselves, the <script> element can be used, to which the policy doesn’t apply; it can load and execute JavaScript from a source of foreign origin. This way, a website located on https://logrocket.com can load the Google Maps library from its provider located under a different origin (i.e., CDN).

By providing the API’s endpoint URL to the <script>’s src attribute, the <script> would fetch the response and execute it inside the browser context.

<script src="https://www.server.com/api/person/1" async="true"></script>

The problem, though, is that, the <script> element automatically parses and executes the returned code. In this case, the returned code would be the JSON snippet shown above. The JSON would be parsed as JavaScript code and, thus, throw an error because it is not a valid JavaScript.

Parsing error
Parsing error

A fully working JavaScript code has to be returned for it to be parsed and executed correctly by the <script>. The JSON code would work just fine had we assigned it to a variable or passed it as an argument to a function — after all, the JSON format is just a JavaScript object.

So instead of returning a pure JSON response, the server can return a JavaScript code. In the returned code, a function is wrapped around the JSON object. The function name has to be passed by the client since the code is going to be executed in the browser. The function name is provided in the query parameter called callback.

After providing the callback’s name in the query, we create a function in the global (window) context, which will be called once the response is parsed and executed.

https://www.server.com/api/person/1?callback=callbackName
callbackName({
  firstName: 'Maciej',
  lastName: 'Cieslar',
});

Which is the same as:

window.callbackName({
  firstName: 'Maciej',
  lastName: 'Cieslar',
});

The code is executed in the browser’s context. The function will be executed from inside the code downloaded in <script> in the global scope.

In order for JSONP to work, both the client and the server have to support it. While there’s no standard name for the parameter that defines the name of the function, the client will usually send it in the query parameter named callback.

Implementation

Let’s create a function called jsonp that will send the request in the JSONP fashion.

let jsonpID = 0;

function jsonp(url, timeout = 7500) {
  const head = document.querySelector('head');
  jsonpID += 1;

  return new Promise((resolve, reject) => {
    let script = document.createElement('script');
    const callbackName = `jsonpCallback${jsonpID}`;

    script.src = encodeURI(`${url}?callback=${callbackName}`);
    script.async = true;

    const timeoutId = window.setTimeout(() => {
      cleanUp();

      return reject(new Error('Timeout'));
    }, timeout);

    window[callbackName] = (data) => {
      cleanUp();

      return resolve(data);
    };

    script.addEventListener('error', (error) => {
      cleanUp();

      return reject(error);
    });

    function cleanUp() {
      window[callbackName] = undefined;
      head.removeChild(script);
      window.clearTimeout(timeoutId);
      script = null;
    }

    head.appendChild(script);
  });
}

As you can see, there’s a shared variable called jsonpID — it will be used to make sure that each request has its own unique function name.

First, we save the reference to the <head> object inside a variable called head. Then we increment the jsonpID to make sure the function name is unique. Inside the callback provided to the returned promise, we create a <script> element and the callbackName consisting of the string jsonpCallback concatenated with the unique ID.

Then, we set the src attribute of the <script> element to the provided URL. Inside the query, we set the callback parameter to equal callbackName. Note that this simplified implementation doesn’t support URLs that have predefined query parameters, so it wouldn’t work for something like https://logrocket.com/?param=true, because we would append ? at the end once again.

We also set the async attribute to true in order for the script to be non-blocking.

There are three possible outcomes of the request:

  1. The request is successful and, hopefully, executes the window[callbackName], which resolves the promise with the result (JSON).
  2. The <script> element throws an error and we reject the promise.
  3. The request takes longer than expected and the timeout callback kicks in, throwing a timeout error.
const timeoutId = window.setTimeout(() => {
  cleanUp();

  return reject(new Error('Timeout'));
}, timeout);

window[callbackName] = (data) => {
  cleanUp();

  return resolve(data);
};

script.addEventListener('error', (error) => {
  cleanUp();

  return reject(error);
});

The callback has to be registered on the window object for it to be available from inside the created <script> context. Executing a function called callback() in the global scope is equivalent to calling window.callback().

By abstracting the cleanup process in the cleanUp function, the three callbacks — timeout, success, and error listener — look exactly the same. The only difference is whether they resolve or reject the promise.

function cleanUp() {
  window[callbackName] = undefined;
  head.removeChild(script);
  window.clearTimeout(timeoutId);
  script = null;
}

The cleanUp function is an abstraction of what needs to be done in order to clean up after the request. The function first removes the callback registered on the window, which is called upon successful response. Then it removes the <script> element from <head> and clears the timeout. Also, just to be sure, it sets the script reference to null so that it is garbage-collected.

Finally, we append the <script> element to <head> in order to fire the request. <script> will send the request automatically once it is appended.

Here’s the example of the usage:

jsonp('https://gist.github.com/maciejcieslar/1c1f79d5778af4c2ee17927de769cea3.json')
  .then(console.log)
  .catch(console.error);

Here’s a live example.

Summary

By understanding the underlying mechanism of JSONP, you probably won’t gain much in terms of directly applicable web skills, but it’s always interesting to see how people’s ingenuity can bypass even the strictest policies.

JSONP is a relic of the past and shouldn’t be used due to numerous limitations (e.g., being able to send GET requests only) and many security concerns (e.g. the server can respond with whatever JavaScript code it wants — not necessarily the one we expect — which then has access to everything in the context of the window, such as localStorage and cookies). Read more.

Instead, we should rely on the Cross Origin Resource Sharing (CORS) mechanism to provide safe cross-origin requests.

Also available on LogRocket.