Sitemap

How do compilers work?

5 min readSep 28, 2026

--

You use a compiler whenever you run a build command for your application. It accepts the source code you created using your favourite IDE (e.g., VS Code, IntelliJ) and returns an output. The output can be different depending on the compiler type. It can be machine code or another lower-level language. There are usually three main steps in compilers

  • Frontend: This basically parses the input, creates a structured output, and spits out more lower-level language. The output is still readable by humans, but it is harder than the original language. It also validates the syntax at this stage because structured data can’t be created unless the syntax is correct
  • Middle-end: It makes all sorts of logical optimizations at this step. E.g if you are summing some constant numbers, then it computes the result at compile time and replaces it. These optimizations are completely at the language level. We are still not at the hardware level
  • Backend: The result of the previous step is converted to machine code. It optimizes the code based on the target environment, CPU architecture, and more

This three-step architecture allows reusing different parts of the compiler independently. E.g., you can replace the middle-end, and it should still work. I am not going to explain the details of each step because they need their own article, IMO

Native compiler

This one is quite straightforward. Everything happens on the same architecture/OS. The compiler gets the source code and writes the machine code for the host that it is currently on. It is very easy to set up, no need for a separate build machine, uses fewer resources, and can create highly optimized code

Cross compiler

It is similar to a native compiler except the fact that it produces code for a different machine. This requires a good understanding of the target architecture and OS. It uses more resources than the native compiler and is usually harder to set up because, imagine you need to create the machine code for Windows on Linux. You will be making a lot of workarounds. One good use of this type of compiler is embedded systems. It is not possible to compile a program on the target system because of the low resources, so people come up with this idea

Just-in-Time (JIT)

The main compiler takes your code as input and writes it in an intermediate language (e.g., IL, bytecode). The output is still readable by humans, but it is much harder than the original language. Then, JIT transforms the intermediate language into machine code

There are two actors in this type of compiler. Interpreter and JIT. The JIT compiles the code that is frequently used into machine code and keeps using that version. The interpreter, on the other hand, loads and executes the rest of the code line by line on the fly each time. This is why the hot paths in the application are faster than the others. Now, your application loads only the necessary part of the code; it can support any platform that has a runtime environment (the thing that transforms the intermediate language), and above all, the parts that are run frequently can be optimized/cached significantly based on the runtime behaviour, which is not available at build time.

For example;

  • It can inline a method and get rid of the overhead of a function call (pushing data to the stack, changing memory addresses, returning data)
  • Unroll a for loop if the loop count is the same on every call
  • Replace the object with primitives if the object is not used outside of the scope of the execution
public int sum(List<int> numbers){
var sum=0;
for (var number : numbers) {
sum+=number;
}
}

// Before
var result=sum(List.of(1, 2, 3))

// After running this function too many times with the same input
var sum=6

It does all these optimizations in a safe manner so your application correctness doesn't change. It basically makes a guess and uses the original version if it is not correct

Get Akın Tekeoğlu’s stories in your inbox

Join Medium for free to get updates from this writer.

Maybe you already realize what the downside of this type of compiler is. First of all, you need to preserve a lot of information to make these kinds of optimizations, which makes the overall size of the application increase; secondly, the app starts slowly because it reads the code, writes the machine code, and executes it. The parts that are not used frequently will be relatively slow when compared to hot paths

Ahead Of Time (AOT)

In contrast, AOT does everything at build time and produces the machine code. It uses all types of optimization techniques during the build time, and the application performance becomes more predictable.

Unlike JIT, the app starts much faster because there is no intermediate language to process; it doesn’t need a separate runtime environment; the output size is usually smaller; and it is not human-readable, so it is better when you don’t want to share your source code

It is not possible to make runtime optimizations because the code is very low-level at this point. However, it can still apply the same optimization techniques if the variables are known at compile time.

AOT is not limited to compilers, and even some frameworks are doing these kinds of optimizations at the language level, like the following:

  • Spring JPA writes necessary SQL queries at compile time for CRUD repositories
  • Spring generates reflection metadata at build time rather than scanning at startup

The main idea behind AOT is to do all types of micro-optimizations during build time and make the application as efficient as possible by sacrificing runtime optimizations

Transpiler

I included this on purpose because you are transpiling the code even though you ran “npm run build”. This is somewhat similar to step one (frontend), but the output is another high-level language. The most well-known example is TypeScript-JavaScript. This one exists mostly for backward-compatibility reasons because browsers only support JavaScript, but people wanted to have something better, so they created TypeScript, which is a superset of JavaScript. The transpiler still parses your code, creates structured data, and writes JavaScript code for it. It can also do some optimizations, as we mentioned before. You can also tell which browser should be supported so that it will write the JavaScript accordingly

Bootstrap Compiler

This one is a little mind-boggling. Imagine we are creating a new language called Y, and we want to use the same language to create the compiler as well. How are we going to compile the compiler without creating the language first? Who wrote the first compiler? :)

People usually write another compiler that compiles just the basic instructions, and the program is sent to this primitive one. Now we have a working compiler, but it doesn’t support all the nice features we want. Now, we can add more stuff by using the simple instructions and compile it again. Here is a simple diagram to show how it works:

Press enter or click to view image in full size
Bootstraping a compiler

Conclusion

I tried to give you a high-level overview of compilers in general. Please keep in mind that the majority of the stuff is oversimplified to let readers to understand better. My main goal is to give you the essence of each so you can go in depth if you like

Compilers are quite important in programming, and they can affect the development lifecycle, runtime performance, and even cause incorrect behaviour in the case of transpilers. It will give you a better idea if you know them in depth and let you understand why your code behaves the way it behaves

--

--

Akın Tekeoğlu
Akın Tekeoğlu

Written by Akın Tekeoğlu

0 followers

A simple man making his way through the galaxy