Miner’s Advice #2: Dare to Experiment

Working in software development means being in a constant state of learning, if not by choice, by necessity; our industry moves exceedingly fast, and there’s always a new language, framework, library, tool, or technique to learn.

In such an environment, the very skill of learning is one of the most important skills one can cultivate. So, with that in mind, I want to share with you a simple yet unreasonably powerful technique that I have used extensively to learn things and make them stick.

The technique I’m talking about is experimentation.

Experimentation is about investigating cause and effect relationships through procedures we call experiments, which by carefully exercising causes in a controlled environment, we’re able to observe the effects that arise and establish the link between each cause and its associated effects.

There are mainly three things that make this technique such a powerful learning device:

First, it is an active learning method that exercises various skills like asking the right questions, formulating hypotheses, setting up scenarios, analyzing outcomes, etc, which greatly increases one’s engagement and retention.

Second, it is an extremely concrete and reliable source of information, as experiments act as primary sources and let you "experience" the answers to your questions firsthand through practical scenarios.

Third, it lets us answer very specific questions that many times are either addressed indirectly or are not addressed at all in secondary sources of information (e.g. documentation).

Although experimenting has a lot of benefits and is within everyone’s reach, I feel that this technique is both underestimated and underused by developers.

In this post, I intend to show why experimentation should be a part of your learning toolbox when to experiment, and how to do it effectively, so today, I propose we put on our imaginary lab coats, and protection goggles and start experimenting.

Looking for Answers

In our daily lives as developers, we’re often faced with a variety of technical questions like: "What error does this HTTP client library throw when there’s a network error?", "When should I use React’s useLayoutEffect instead of useEffect?", "Why is this variable stale in this closure?", "What happens if I return a reference to a local variable in C++?", "What is the difference between transaction isolation levels in a relational database?", and so on.

Usually, to answer these questions we might check the documentation, read a blog post, watch a video, or ask a colleague. And that’s fine, really, as these sources will often answer these questions satisfactorily.

Sometimes, however, one of these two things might happen:

  • These sources do not cover the entirety of your questions, maybe because some of these questions are very specific or because the content is incomplete.
  • You’re not entirely sure you understood the answer completely, either because it has some nuance to it, or just because it is a little ambiguous or poorly phrased.

For things like blog posts, videos, courses, and even documentation, there’s only so much information they can hold, and as such, they cannot have the answer to everything; and even for questions that do have an answer, sometimes these answers are going to be generic to cover lots of cases at once, or they might be a little bit ambiguous/hard to understand either because you may lack some context or because they are poorly phrased.

So, in either of these cases, how would you proceed?

You might look for other sources, like a different blog post, or another video, maybe create a post on Reddit or StackOverflow, or open an issue in a GitHub repository, but even then the outcome might be the same.

So, what if instead of "outsourcing" the question to a third party we took matters into our own hands?

One way of solving this problem definitively is by reading the source code (when that’s possible), as the code is the ultimate truth bearer. But then, more often than not, this approach would take much more time and effort than we’re willing to commit, and depending on how the code is written, it may suffer from the same problem as the aforementioned sources, by being hard to understand it thoroughly.

A viable alternative, then, is precisely using experiments.

By carefully crafting experiments that exercise the relevant scenarios for our questions, we can observe the results of these experiments and conclude ourselves, and because these experiments are very concrete and can be made very precise, so will our conclusions.

In the above scenarios, experimentation is a particularly useful learning technique as it allows us to get definitive answers to questions that are hard to answer just by relying on secondary sources. Even when we can answer a question by consulting these secondary sources alone, it might still be worth it to carry out an experiment that confirms the answer we obtained from these sources as a way to ensure we understood it correctly, and also as a way to enhance the learning process.

I want you to think of experimentation not as a replacement for these secondary sources, but rather as a complementary technique, where we’ll use documentation, videos, courses, etc as a base and then use experimentation to sharpen our understanding and fill the gaps.

Now let me tell you a little personal anecdote:

When I was a teenager, there was a friend of mine with whom I sometimes engaged in "philosophical" discussions. Back then, I was an aspiring musician, and he was a biology undergraduate student.

We both shared an admiration (which later for me would become a platonic love) for Mathematics and in one of these discussions he said something that stuck with me ever since:

The mathematician’s laboratory is himself.

In Science, and in all forms it takes, be it Physics, Biology, Chemistry, Engineering, etc, experimentation is a key practice to advance knowledge.

However, in most fields, experimentation requires specialized tools, a controlled environment, and resources that are often expendable, which usually makes experimenting a costly undertaking that needs to be done very carefully lest we waste these precious resources.

In this sense, Mathematicians hold a privileged position, because all the tools, environment, and resources they need are readily available in their minds.

Although software development is not exactly a purely a priori science as is Mathematics, I believe that currently, we, software developers, find ourselves in a similar predicament.

Long gone is the era of punch cards, mainframes, expensive hardware ,and inaccessible computers. Nowadays, we all have access to moderately powerful personal computers, and programming languages are ubiquitous and can be compiled/interpreted on lots of different platforms, moreover, the cloud has made it easier than ever to set up complicated infrastructure.

So, given that experimenting is so cheap for us, why not do it more often?

Moving forward, let’s take a look at what experimenting looks like in practice, and how to do that effectively.

Experimenting

The goal of this experimentation process is always to learn something, so the very first thing we need to do is to define what exactly we’re trying to learn.

It may be something very narrow like understanding whether sessionStorage resets when we refresh the page, or very broad like understanding how the CSS box model works.

Then, once we have a goal in mind, we’re going to carry out a series of experiments that move us closer towards that goal, where each experiment will contribute with a "piece of the puzzle" by answering a specific question.

To understand what this process looks like in practice, I’ll walk you through an example of experimentation where our goal will be to understand CORS and how it works.

To ensure we’re all on the same page, CORS (Cross Origin Resource Sharing), simply put, is a way to "circumvent" browsers’ SOP (Same Origin Policy), which is a policy that prevents a web page from accessing resources from different origins for security reasons. If you’re a web dev, you most likely already stumbled upon CORS errors.

Each experiment is composed of 5 steps:

  1. Asking a question
  2. Designing the experiment
  3. Setting up the experiment
  4. Running the experiment
  5. Observing the results

Asking a question

Once we have a goal in mind, we need to ask questions whose answers will get us closer to accomplishing that goal.

So, in our example, our first question will be:

Does fetching some data using the browser’s fetch from a URL that has the same domain, subdomain and protocol, but a different port can cause a CORS error?

Usually, each experiment will answer a very specific question as experiments are also very specific, in the sense that they usually only exercise one scenario at a time.

So, in most cases, you’ll have to break up your goal into several smaller questions.

Designing the experiment

With a question "in hand", the next step is to design the experiment.

The design is probably the most important part of the process, as the experiment needs to be designed correctly in order to answer our question satisfactorily.

Also, when designing an experiment, we want to minimize the time we’ll spend setting it up and make it as precise as possible by eliminating any kind of "noise", that is, unrelated factors that could influence the experiment but that are outside of the scope of our question.

Recall that in our example, we want to know whether fetching some data using the browser’s fetch from a URL that has the same domain, subdomain, and protocol, but a different port causes a CORS error.

To do that, we’ll need two servers, one that’s going to serve the web page from a given domain, and another that will serve some JSON API from the same domain, subdomain and protocol, but with a different port.

Then, the page that’s served from the first server will fetch some data from the second server, and we’ll see whether this causes a CORS error or not.

Setting up the Experiment

After designing our experiment, we need to "implement" it.

For the server that serves the web page, we’ll create a barebones HTML server:

import Fastify from "fastify";
import { readFile } from "fs/promises";

const fastify = Fastify({
  logger: true,
});

fastify.get("/", async (req, res) => {
  const html = await readFile("./index.html");

  res.header("Content-Type", "text/html");
  res.code(200).send(html);
});

try {
  // This one uses port 3000
  await fastify.listen({ port: 3000 });
} catch (err) {
  fastify.log.error(err);
  process.exit(1);
}

The HTML that is sent is this one:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const main = async () => {
        // We're fetching some data from
        // the API
        const response = await fetch("http://localhost:3001/");
        const data = await response.json();

        document.body.innerText = data.message;
      };

      main();
    </script>
  </body>
</html>

Then, for the JSON API server, we’ll create another application that has a single endpoint:

import Fastify from "fastify";

const fastify = Fastify({
  logger: true,
});

fastify.get("/", async (req, res) => {
  res.header("Content-Type", "application/json");
  res.code(200).send({
    message: "Hello World",
  });
});

try {
  // This one uses port 3001
  await fastify.listen({ port: 3001 });
} catch (err) {
  fastify.log.error(err);
  process.exit(1);
}

Notice that both servers are under localhost and use plain HTTP, however, the server that serves the webpage is listening on port 3000 and the API is listening on port 3001.

Running the Experiment

Now that the experiment is set up, we run it.

Running the experiment might mean a different thing for each specific experiment. In some cases, it might mean running a database query, calling a function, refreshing a page, etc.

In our example, running the experiment is simply starting both servers and then accessing the web page.

Observing the Results

After running the experiment, we then observe the results and draw conclusions.

In our example, after starting both servers and accessing the web page, we have to look at the console to see whether we get any errors.

And there it is!

What this tells us is that even though both origins share the same domain, subdomain and protocol, as their ports differ, they are considered to be different origins, and thus trigger SOP, causing a CORS error.

Iterating

We finished our experiment and got our answer, but recall that the experimentation process as a whole is not composed of a single experiment, but of a series of experiments where usually, each experiment builds upon the previous one in an iterative fashion.

In this first experiment we’ve shown how to proceed step by step, so now, we’ll move to subsequent experiments where we’ll move "faster" without describing each step as thoroughly as we did so far.

This way, you’ll get an idea of what this iterative process looks like in practice where each answer that we get leads to subsequent questions, that then give rise to new experiments, and so on.

Back to our example, the next thing we’ll do is to solve the CORS issue we got.

By reading the documentation, we learned that when we’re fetching some data from a different origin, we need to include a Access-Control-Allow-Origin header on the server response so that the browser understands that it is allowed to load a resource from the client’s origin.

So let’s do that and see whether this solves our problem:

fastify.get("/", async (req, res) => {
  res.header("Content-Type", "application/json");
  res.header("Access-Control-Allow-Origin", "localhost:3000");
  res.code(200).send({
    message: "Hello World",
  });
});

We’re now setting the Access-Control-Allow-Origin header to the client’s origin, which is localhost:3000.

Now let’s reload the page and see whether we’re able to fetch the data:

Hmm, we’re still getting a CORS error, but this time the message is a little bit different.

The previous error message told us that there was no Access-Control-Allow-Origin header present in the server’s response, but now it is telling us that although the header is present, the value is not equal to the supplied origin.

After thinking for a while, I noticed that we didn’t include the protocol (the http:// part) in the origin, but it is part of it, so let’s include it and try again.

fastify.get("/", async (req, res) => {
  res.header("Content-Type", "application/json");
  res.header("Access-Control-Allow-Origin", "http://localhost:3000");
  res.code(200).send({
    message: "Hello World",
  });
});

Now we reload the page once again:

Nice! This time we got rid of the CORS error!

Okay, but the docs tell us that in some cases there’s a preflight request that’s made before the actual request to ensure the web page’s origin is allowed to access that resource.

So why don’t we see any preflight requests?

Oh! It seems that some requests, called simple requests do not need to issue a preflight request.

Hmm, so according to the documentation, even though we’re making a GET request, if we include a non safelisted header like Authorization, the browser should issue a preflight OPTIONS request to the same URL.

Let’s test it out!

We’re going to modify our HTML page so that the fetch request includes the Authorization header:

<script>
  const main = async () => {
    const response = await fetch("http://localhost:3001/", {
      headers: {
        Authorization: "Bearer some-token",
      },
    });
    const data = await response.json();

    document.body.innerText = data.message;
  };

  main();
</script>

Then let’s reload the page:

Now we’re getting a CORS error once again, but this time the error message is different, as it does mention the preflight request.

So, theoretically, to solve this problem, we need to create a route that matches the OPTION method for the same URL of the resource we’re trying to access and answer with the Access-Control-Allow-Origin header:

fastify.options("/", async (req, res) => {
  res.header("Access-Control-Allow-Origin", "http://localhost:3000");
  res.code(200).send("Ok");
});

After reloading the page:

We’re still getting a CORS error, but now the error message is complaining about something else. The error is telling us about an Access-Control-Allow-Headers header that is absent in the preflight response, but what does this header even do?

The documentation tells us that for cross-origin requests that include non safelisted headers, the server’s response needs to include an Access-Control-Allow-Headers with a comma-separated list of the headers that the client is allowed to include in the request.

Let’s modify the server accordingly:

fastify.options("/", async (req, res) => {
  res.header("Access-Control-Allow-Origin", "http://localhost:3000");
  res.header("Access-Control-Access-Headers", "Authorization");
  res.code(200).send("Ok");
});

Reloading the page again:

Nice! It worked!

With this last bit, we finish our example.

There are several other things that we could explore, experiment, and learn regarding CORS, but I think with what we saw so far we’re able to have a clear picture of what experimenting looks like in practice and how to do it.

Finishing Remarks

Although experimenting does not and should not replace other sources of information like documentation, Q&A forums, discussions with peers, etc, it has some unique characteristics that make it an extremely powerful learning tool.

I hope that with this post, you’re a little bit more inclined to try it out and incorporate it into your learning sessions.

We want to work with you. Check out our "What We Do" section!