Published: Jun 14, 2026

Building Restly: Integrating with daveshanley's Vacuum OpenAPI Linter

Vacuum + Restly Logos

Restly aims to simplify the creation of OpenAPI spec files. Its GUI form-based editor is designed to enable API designers and engineers to create or edit OpenAPI files without wrestling with JSON and YAML.

Part of crafting an OpenAPI file is conforming to your API's rules and style guide. For example, "We use kebab-case for API paths," "We use camelCase for component properties," or "We require 'x-' prefixes on all our custom headers." As an API gains more contributors and grows in functionality through additional operations and components, a Spectral ruleset becomes essential for enforcing its style guide.

Having an OpenAPI linter running as you make changes to your OpenAPI file will help you catch problems early. You can also use the linter 's real-time results to progressively address issues. While integrating an OpenAPI linter into Restly proved critical, it presented some interesting, unforeseen challenges.

We have an article dedicated to discussing what OpenAPI linting is and why you should use it. In this article, however, we'll focus on the technical obstacles Restly faced when integrating with the de-facto open-source tools for OpenAPI linting: Stoplight Spectral and Vacuum.

Deciding on an OpenAPI Linter

Stoplight Spectral is the most popular OpenAPI linter. It has been around since late 2018, and is well tested and supported by Smartbear (who acquired Stoplight). Written in Typescript/Javascript, Stoplight Spectral would have been an easy choice to integrate with Restly, whose browser client is written in React-Typescript.

However, we also wanted to run any OpenAPI linter we selected server-side. Restly plans on building a Git integration to both push and pull OpenAPI changes directly from repositories. Restly's backend is written in Go, and maintaining our development speed and predictability relies on keeping our tech stack simple. We wanted to avoid adding a Javascript environment.

Conveniently, another OpenAPI linter exists that fully supports the Spectral ruleset and is written in Go: Vacuum. In addition to being a better fit with our backend infrastructure, Vacuum's performance improvement over Stoplight Spectral is an added bonus that would significantly benefit Restly's users. Vacuum also includes a developer API guide that helped us get started quickly.

Now we faced the opposite issue: Vacuum is not written in Typescript/Javascript, which meant integration with our browser client would not be as straightforward. Fortunately, it is straightforward to use Go binaries on the web by compiling them to WASM. This presented a more straightforward path to integrating Vacuum with our entire tech stack than Stoplight Spectral.

Building a Vacuum WASM Binary

Getting the first WASM binary built was problematic due to Vacuum's dependency with the Doctor, an open-source online toolset for OpenAPI spec editing and analysis. Some imports from the Doctor included SQLite dependencies that did not support compiling to WASM.

After going back and forth debating forking the project to manage these dependencies ourselves, we discovered that locking the dependency version of the Doctor to v0.0.66 seemed to work. This is not a long-term fix, but for right now, we were happy we got the binary build working!

Once that was resolved, the actual binary code was not very complex. We have our first version shown below.

//go:build js && wasm

package main

import (
	"encoding/json"
	"syscall/js"

	"github.com/daveshanley/vacuum/model"
	"github.com/daveshanley/vacuum/motor"
	"github.com/daveshanley/vacuum/rulesets"
)

const version = 1

func main() {
	js.Global().Set("version", version)
	js.Global().Set("lintJson", js.FuncOf(lintJson))
	select {}
}

type Response struct {
	Result json.RawMessage `json:"result,omitempty"`
	Error  string          `json:"error,omitempty"`
}

func lintJson(this js.Value, args []js.Value) any {
	print("[go_vacuum_wasm] lintJson called\n")
	if len(args) != 1 {
		print("[go_vacuum_wasm] error: expected 1 argument\n")

		resp, _ := json.Marshal(Response{
			Error: "expected 1 argument",
		})
		return string(resp)
	}

	input := args[0].String()

	print("[go_vacuum_wasm] input length: ", len(input), "\n")

	defaultRS := rulesets.BuildDefaultRuleSets()
	recommendedRS := defaultRS.GenerateOpenAPIRecommendedRuleSet()

	print("[go_vacuum_wasm] rulesets configured, starting lint\n")

	lintingResults := motor.ApplyRulesToRuleSet(
		&motor.RuleSetExecution{
			RuleSet:     recommendedRS,
			Spec:        []byte(input),
			SilenceLogs: true,
		})

	print("[go_vacuum_wasm] lint results length: ", len(lintingResults.Results), "\n")

	var lintResult struct {
		RuleFunctionResults []model.RuleFunctionResult `json:"ruleFunctionResults"`
		SeverityErrorCount  int                        `json:"severityErrorCount"`
		SeverityWarnCount   int                        `json:"severityWarnCount"`
		SeverityInfoCount   int                        `json:"severityInfoCount"`
		SeverityHintCount   int                        `json:"severityHintCount"`
		SeverityNoneCount   int                        `json:"severityNoneCount"`
	}

	lintResult.RuleFunctionResults = lintingResults.Results
	for _, r := range lintingResults.Results {
		switch r.Rule.Severity {
		case model.SeverityError:
			lintResult.SeverityErrorCount++
		case model.SeverityWarn:
			lintResult.SeverityWarnCount++
		case model.SeverityInfo:
			lintResult.SeverityInfoCount++
		case model.SeverityHint:
			lintResult.SeverityHintCount++
		case model.SeverityNone:
			lintResult.SeverityNoneCount++
		}
	}

	print("[go_vacuum_wasm] start json marshal\n")

	resultJson, err := json.Marshal(lintResult)
	if err != nil {
		print("[go_vacuum_wasm] error: failed to marshal final results\n")
		resp, _ := json.Marshal(Response{
			Error: err.Error(),
		})
		return string(resp)
	}

	resp, _ := json.Marshal(Response{
		Result: resultJson,
	})

	print("[go_vacuum_wasm] write response\n")

	return string(resp)
}

With everything in place, we built the WASM binary using:

GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" -o govacuum.wasm cmd/go_vacuum_wasm/main.go

And looked at the results:

55M May 5 13:34 govacuum.wasm

Our binary output was 55MB. This is a massive binary for web standards. However, let's not worry about this problem right now. We were still in the PoC stage and wanted to get a running integrated version of the WASM into Restly.

Using the Vacuum WASM Binary on Web

Go has an excellent wiki on using Go WASM binaries on web. We included the wasm_exec.js script to make working with Go WASM binaries easy in the header:

<script src="wasm_exec.js"></script>

And instantiated our binary like so:

const result = await WebAssembly.instantiateStreaming(
	fetch(goVacuumWasmUrl),
	go.importObject,
);

go.run(result.instance);

const lintJson = (window as any).lintJson;
const lintResult = lintJson(JSON.stringify(oasSpec));

And after some plumbing, we were able to run the WASM binary on web in Restly! Below was outputted to the console.

13:41:58.279 wasm_exec.js:22 [go_vacuum_wasm] lintJson called
13:41:58.287 wasm_exec.js:22 [go_vacuum_wasm] input length: 163524
13:41:58.362 wasm_exec.js:22 [go_vacuum_wasm] rulesets configured, starting lint
13:42:00.679 wasm_exec.js:22 [go_vacuum_wasm] lint results length: 5
13:42:00.680 wasm_exec.js:22 [go_vacuum_wasm] start json marshal
13:42:00.684 wasm_exec.js:22 [go_vacuum_wasm] write response

Now that we've completed the PoC, it was time to tackle two major issues with this integration.

  1. Running the WASM directly on the main thread to lint an OpenAPI file would block the UI, necessitating integration with web workers to run Vacuum in the background.
  2. The binary 's large size. As Restly is hosted on Cloudflare, assets exceeding 25MB are not accepted.

Using Browser Workers

We needed Vacuum to run in the background for 2 reasons:

  1. Vacuum may take a few seconds to run, and completely blocking the UI was absolutely non-negotiable.
  2. We wanted the option to stop Vacuum from running when a user begins to make changes again.

To do this, we used web workers to trigger background processing of Vacuum, and used the terminate API to stop Vacuum from running (as far as we knew, there was no elegant way to stop Vacuum in the middle of linting).

The result is the following web worker. At this point, we updated our Vacuum binary to support ingesting a custom Spectral Ruleset via lintJsonWithRuleset:

/// <reference lib="webworker" />

importScripts("/wasm_exec.js");

self.onmessage = async (
  event: MessageEvent<{
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    oasSpec: any;
    goVacuumWasmUrl: string;
    spectralRuleset?: string;
  }>,
) => {
  const { oasSpec, goVacuumWasmUrl, spectralRuleset } = event.data;

  const go = new Go();

  self.postMessage({ type: "update", update_type: "loading_go_wasm" });

  const result = await WebAssembly.instantiateStreaming(
    fetch(goVacuumWasmUrl),
    go.importObject,
  );

  self.postMessage({ type: "update", update_type: "go_wasm_loaded" });

  go.run(result.instance);

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const lintJsonWithRuleset = (self as any).lintJsonWithRuleset;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const lintJson = (self as any).lintJson;

  if (spectralRuleset) {
    const lintResult = lintJsonWithRuleset(
      JSON.stringify(oasSpec),
      spectralRuleset,
    );
    self.postMessage({ type: "result", result: JSON.parse(lintResult) });
  } else {
    const lintResult = lintJson(JSON.stringify(oasSpec));
    self.postMessage({ type: "result", result: JSON.parse(lintResult) });
  }
};

We then triggered the worker like so:

const lint = (oasSpec: any, spectralRuleset?: any) => {
  const startTime = performance.now();

  const worker = new Worker(
    new URL("../workers/lintWorker.ts", import.meta.url),
  );

  workerRef.current = worker;

  worker.onmessage = (event) => {
    if (event.data.type === "result") {
      const endTime = performance.now();
      const duration = endTime - startTime;
      console.log("duration", duration);
      console.log("Lint results received from worker:", event.data.result);
      setLintResults(event.data.result.result);
    }
  };

  worker.onmessageerror = (error) => {
    console.error("Worker message error:", error);
  };

  worker.postMessage({
    oasSpec,
    goVacuumWasmUrl, // Supplied as an env variable
    spectralRuleset: spectralRuleset
      ? JSON.stringify(spectralRuleset)
      : undefined,
  });
};

And if we needed to end the worker (when a user made a change to the OpenAPI file) we would call:

workerRef.current?.terminate();

Always re-initializing the worker to end a job is extreme and results in unnecessary allocations. However, we had no other way to force-stop the Vacuum analyzer mid-analyze, and real-world testing showed that the time to restart the worker and to reinstantiate the WASM binary was insignificant.

Investigating the Large WASM Binary

With Vacuum elegantly integrated with Restly, we needed to address a binary size issue. Cloudflare's 25MB upload limit meant we had to shrink the 55MB binary to under 25MB for elegant integration into our browser client.

Our first step was to analyze the WASM binary to identify what was bloating its size. Running the following command, we can visualize the size of each section within the WASM binary (as defined by its header):

$ wasm-objdump -h govacuum.wasm

govacuum.wasm:  file format wasm 0x1

Sections:

   Custom start=0x0000000e end=0x00000080 (size=0x00000072) "go:buildid"
     Type start=0x00000086 end=0x000000c1 (size=0x0000003b) count: 11
   Import start=0x000000c7 end=0x000003b3 (size=0x000002ec) count: 25
 Function start=0x000003b9 end=0x000093ab (size=0x00008ff2) count: 36847
    Table start=0x000093b1 end=0x000093b7 (size=0x00000006) count: 1
   Memory start=0x000093bd end=0x000093c1 (size=0x00000004) count: 1
   Global start=0x000093c7 end=0x000093f0 (size=0x00000029) count: 8
   Export start=0x000093f6 end=0x00009417 (size=0x00000021) count: 4
     Elem start=0x0000941d end=0x000203a5 (size=0x00016f88) count: 1
     Code start=0x000203ab end=0x01c198c5 (size=0x01bf951a) count: 36847
     Data start=0x01c198cb end=0x037227bb (size=0x01b08ef0) count: 100000
   Custom start=0x037227c1 end=0x03722808 (size=0x00000047) "producers"

The Code section is about 29MB. Go binaries are generally large because they include the entire Go runtime, supporting functionalities such as goroutines.

However, the more interesting part is the 28MB Data section. The WASM module should not contain any embeds or artifacts that would bloat Data to such a large size. To see what is stored in the Data section, we ran:

wasm-objdump -j Data -x govacuum.wasm > govacuum.data.txt

This command exports the data section, in both hex and string formats, to govacuum.data.txt. Upon examining the output, we found large sections of blank or repeating data:

- 1c676a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c676b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c676c0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c676d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c676e0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c676f0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c67700: 0000 0000 0000 0000 0000 0000 0000 0000  ................
...
  - 1c69200: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c69210: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c69220: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c69230: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c69240: 0000 0000 0000 0000 0000 0000 0000 0000  ................
  - 1c69250: 0000 0000 0000 0000 0000 0000 0000 0000  ................
And it goes on and on...

Some areas are more pattern-like, with repeating blocks of 0000 and entries every N segments. This means large blocks of uninitialized data are being set. After some testing, it is clear that something within Vacuum is resulting in such a large WASM binary.

Is this a sign that we should fork Vacuum and strip unnecessary entries that are causing this bloat? Should we look back and see if there is an elegant integration with Stoplight Spectral? Should we just bite the bullet and host the 55MB file somewhere and require it to be downloaded on all clients? Actually, that last one seems a lot more practical than you would expect.

Going with hosting the 55MB Binary

Amidst all the discussion, and in the spirit of "getting things to work as a first iteration," we decided to host govacuum.wasm in our backend as a static asset. Instead of including it within the frontend's code repository and tightly coupled, the binary would be served as a static asset from our backend, which had no file size limit. The first time we ran this on our production instance, we were in for a pleasant surprise:

Path                    Method    Size        Download Speed
/static/govacuum.wasm   GET       9,609 kB	  1.29 s

In a production instance, over the wire, the browser needed to download a ~10MB asset (not a 55MB asset). This is still large for web standards, but for a 10Mb/s internet connection, the asset could be downloaded in about 10 seconds. Thank you gzip!

We mentioned earlier that we saw large blocks of empty or repeating data segments. The benefit of such blocks is that they increase the compression ratio dramatically; large files can be compressed into much smaller files. For govacuum.wasm, we saw a compression ratio of 11:2 (a 5.5x reduction).

With this in mind, we needed to make a few more modifications to provide a great experience for our production users.

Versioning, Caching, and Eager Loading

With govacuum.wasm hosted as a static asset from our backend, a few more adjustments were needed to make this setup production-ready:

Versioning the WASM Binary

Since we would be making changes to govacuum.wasm regularly, we needed a way to version those static assets. For now, we decided on a basic incrementing ID for newer versions of the asset, starting with govacuum.1.wasm. When any change was introduced, we incremented this value to govacuum.2.wasm, hosted both the older and newer binary, and then updated the frontend to use the newer version. We can probably automate this at some point in the future, but for now this works like a charm.

Browser Caching

Since we version our binary as described above, we know that a binary with a specific ID would be immutable; it would never change, ensuring consistency. Therefore, we can instruct the browser to cache the file for us using the following header:

Cache-Control: "public, max-age=7776000"

As a result, the cost of downloading our 10MB binary would only be incurred once (well, once every 90 days using max-age=7776000).

Eager Loading

Lastly, to provide the best experience for our users, we implemented eager loading to load the binary as early as possible when the page loads; even before the OpenAPI editor is opened. As a result, the user does not experience the ~10-second download time penalty. (Although, if a user jumps straight into the OpenAPI editor, and a new version of the binary is available, they may sense it then. Let's not get caught up in the most nuanced low-probability cases.)

Vacuum Integrated with Restly

It is very exciting to have an OpenAPI linter integrated with our browser client. You can try it today using the default Vacuum-recommended rulesets, or set up your own rules as well. There is much more we want to achieve; for example, integration with Git for automatic linting, tracking scores over time, ruleset verification, and recommended fixes. However, for now, we have reached a very exciting milestone!

Try Restly for Free