# cljs-ajax gives Clojure and ClojureScript the same request interface

> cljs-ajax is an asynchronous HTTP client that wraps goog.net.XhrIo or XmlHttpRequest in ClojureScript and Apache HttpAsyncClient on the JVM, with a shared option map and Transit, JSON, text and raw body formats. It is still published as a 0.9.0 beta, and its two platforms do not behave identically.

**JulianBirch/cljs-ajax** — simple asynchronous Ajax client for ClojureScript and Clojure

- Repository: https://github.com/JulianBirch/cljs-ajax
- Stars: 677 · Forks: 132
- Language: Clojure
- License: not declared
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/julianbirch-cljs-ajax

## One API, two very different HTTP stacks

cljs-ajax is described in the README as a simple Ajax client for ClojureScript and Clojure, and the design goal is stated up front: it exposes the same interface, where useful, in both languages. What sits underneath is not shared at all.

In ClojureScript it is a wrapper around the Closure library's goog.net.XhrIo or the browser's js/XmlHttpRequest. On the JVM it wraps the Apache HttpAsyncClient library. So the same call site compiles to an XHR on the front end and to a connection-pooled async client on the back end, and the library is the layer that hides that split.

The catch is in the phrase where useful, which the README uses deliberately. Several options exist on only one platform. The :progress-handler works only with the goog.net.XhrIo API, and :cookie-policy is marked as available only in Java. The :raw format behaves differently again: it returns the body's java.io.InputStream on the JVM and explicitly does not close it, while on the browser side it sends parameters as a normal form submission. If your code relies on those paths, the abstraction leaks.

The documentation set is split across the repository rather than gathered in the README. There is a FAQ at docs/faq.md, a change log in CHANGES.md, a contribution document, and a docs folder with detailed notes on individual features, including a page on handling responses on the server in Clojure and a page on interceptors.

## The option map is the real API

Every request is a URI followed by a map of options, and that map is where the library's design lives. The GET, POST and PUT helpers take it, as does the more general ajax-request function.

Three keys decide who handles the outcome. The :handler receives the deserialized response for a successful call, and the :error-handler receives an error response. If you omit either, the library falls back to a global atom, default-handler or default-error-handler, and the README is explicit that these defaults are println on the JVM and a console write in ClojureScript. That is a sane development default and a poor production default, so any real use should set the handlers. The :finally key takes no parameters and fires during the callback in addition to anything else.

Two keys control the wire format. :format specifies the request body format and sets the matching Content-Type header, defaulting to :transit. :response-format forces the response to be parsed as a given type by setting the Accept header, and if you leave it out the library sends a permissive Accept header and interprets the body according to the response's own Content-Type.

The remaining keys are ordinary request shaping. :params carries the payload, with :transit and :edn able to send anything while :json, :text and :raw need a map, and GET puts params on the query string while POST puts them in the body. :url-params adds query string parameters and, on a GET, replaces the ones from :params. :timeout is in milliseconds and defaults to 0, meaning no timeout at all, which is worth thinking about before you point it at a flaky endpoint. :headers is a map of HTTP headers.

Then there are the escape hatches. :body sends the exact data you supply and makes both :params and :request-format irrelevant, and the README notes you can pass js/FormData and other raw JavaScript types this way. :with-credentials sets the withCredentials flag on the XHR object.

## Transit by default, and a raw mode that leaks a stream

Four body formats are supported: :transit, :json, :text and :raw. Transit is the default for the request body, which is a sensible choice inside a Clojure ecosystem where the server and client can both read it, and it is why the library ships with a guide on handling responses on the server side in Clojure.

The other three formats are for talking to things that are not Clojure. :text sends parameters as a normal form submission and returns raw text. :raw does the same thing but on the JVM returns the body's java.io.InputStream, and the README marks in italics that it does not close it. That is the sort of detail that turns into a resource leak if you are not paying attention, because closing the stream is left to the caller and nothing in the interface suggests it.

Reading a response back is where :response-format earns its keep. Setting it forces the parse, which is what you want when a server returns JSON with a content type the client would not guess correctly. Leaving it unset makes the client read the response's Content-Type, which is fine for well behaved APIs and wrong for the ones that send everything as text/plain.

The JSON options only apply when :response-format is :json, and the README says so explicitly. :keywords? is a boolean that decides whether keys in returned maps are keywordized. :prefix is a string to strip from the start of the response body, and the documented use is the while(1); prefix that some APIs add to prevent JSON hijacking. The README's advice is blunt on this one, that you should always use it if you have got a GET request. That is a security detail rather than a convenience, and it is the kind of thing that silently stops being applied when a wrapper defaults to a permissive parse.

## GET parameter encoding has two incompatible conventions

The :vec-strategy setting decides how sequences are written into a query string, and the two options are not interchangeable.

With :java, a map holding a vector under the key a renders that vector as a repeated key, so a value of {:a [1 2]} becomes a=1&a=2. With :rails, the same map becomes a[]=1&a[]=2, and the README notes that the rails form is the correct setting for working with HTTP. The library ships with both, and the difference is not cosmetic: one server framework will read the first and another will read the second, and a mismatch produces a silently empty parameter rather than an error.

The example in the README shows how the encoding composes with nested structures. A request with a scalar, a vector, a nested map and a string key writes a=0&b=1&b=2&c[d]=3&c[e]=4&f=5 under the java strategy, and the rails strategy turns the repeated vector keys into bracketed ones while leaving the rest alone. So the setting only affects sequences, and the surrounding structure is encoded the same way in both modes.

The whole thing is a reminder that a wrapper cannot decide this for you. The strategy has to match whatever parses the query string on the other end, and the README states the rails convention is the one for HTTP, which is a good default to know about even if you pick the other one deliberately.

## Interceptors are a hook, and the default atoms are global

The :interceptors option takes a collection of interceptors to run for that request, and the README points at a dedicated page for them. If you do not set it, the library runs the contents of the default-interceptors global atom, which is an empty vector by default. So the mechanism exists in the request path from the start, and the natural place to put shared behaviour such as authentication headers, tracing or request ids is an interceptor rather than a wrapper function at every call site.

The same global-state pattern applies to the handlers, as noted earlier. You can override default-handler and default-error-handler, but they are global to your application or page. That is convenient for a small application, where one place to change the fallback behaviour is a feature, and it is a hazard in a large one, where a library that other code depends on has quietly changed what an unhandled error does.

The :body option completes the picture. When you supply it, both :params and :request-format are ignored, so the body is exactly what you gave it. The README shows the modern form submission case, posting a js/FormData built from a form element, which is the way to send file inputs from ClojureScript. The :body path is also where you bypass the library's own encoding entirely, and therefore where you take responsibility for the Content-Type header, since :body makes :format irrelevant.

The :progress-handler completes the option set on the client side, and it is the one that will not work everywhere.

## Getting it into a project and running the test matrix

The library is published on Clojars, and the README gives the Clojure CLI dependency coordinate using the latest version shown on the Clojars badge:

```clojure
{:deps {cljs-ajax {:mvn/version "0.9.0-beta1"}}}
```

A 0.9.0 beta is the current coordinate, and the repository itself has no GitHub releases, so Clojars and the changelog are your version reference. The last push was on 2026-10-18, so the project is being worked on even though the published artifact is a beta.

A first request looks like this:

```clojure
(GET "/hello" {:params {:foo "foo"}})
```

and a POST with handlers attached:

```clojure
(POST "/send-message"
        {:params {:message "Hello World"
                  :user    "Bob"}
         :handler handler
         :error-handler error-handler})
```

The repository is set up to exercise both platforms. The package.json scripts compile with shadow-cljs, and there are separate targets for node and browser test runs, with karma driving the browser side and a chrome launcher among the dev dependencies. Integration tests run against a real server through a dedicated integration server runner, in both node and browser variants, and there is a build:integration script for the integration bundle. The CI badge in the README points at a ci.yml workflow, with a separate release.yml workflow, and a browser-test directory sits at the root.

That matrix is the practical answer to the platform differences described earlier. The library is tested on the JVM, on node and in a browser, which is a reasonable assurance that a request which works in one place works in the others, at least for the cases the tests cover.

## What to check before you depend on a beta with a split platform

Three things deserve a look before this goes into a codebase.

First, the version. The published coordinate is 0.9.0-beta1 and there are no GitHub releases, so a breaking change can arrive as a new beta on Clojars without a tag to warn you. Pinning the version in deps.edn is the obvious defence, and reading CHANGES.md before an upgrade is the other half.

Second, the platform split. The :raw format leaving a java.io.InputStream unclosed, :cookie-policy existing only in Java, and :progress-handler working only with the XhrIo path are all documented, but they are exactly the corners where shared code quietly diverges. If your application has a request layer that both the server and the client call, those three are where to look first.

Third, the licensing and provenance. The project record lists the license as unknown, with no LICENSE file among the repository's top level entries, and the README points to a Clojars badge and a changelog rather than stating terms. Before you put a library with unstated terms into a commercial product, find out what Clojars and the project actually grant.

The alternative to this library is to use the platform APIs directly, which is what the library does for you. On the browser that means js/XmlHttpRequest or fetch with your own body encoding, and on the JVM it means Apache HttpAsyncClient with your own connection handling and your own serialization choice. The difference in approach is that cljs-ajax buys you a single option map, one set of interceptors and one error handling convention across both sides, and it charges you a beta version, a set of platform specific exceptions, and a dependency on the library's idea of the right defaults.

## Conclusion

Adopt cljs-ajax if you want one request interface shared between your Clojure server code and your ClojureScript front end, with the same handlers, interceptors and body formats on both sides, and if you are willing to work with a 0.9.0 beta pulled from Clojars. Do not adopt it if you need a versioned stable release, if you need the same behaviour on the JVM and in the browser down to stream handling, or if you rely on progress events outside the XhrIo path. Before you depend on it, check the CHANGES.md file and the docs folder for the behaviours that differ by platform, confirm that the default error handling, which is println on the JVM and a console write in ClojureScript, is what you actually want in production.

## FAQ

### How do I add cljs-ajax to a Clojure or ClojureScript project?

The README gives a Clojure CLI dependency entry using the Clojars artifact, `{:deps {cljs-ajax {:mvn/version "0.9.0-beta1"}}}`. The project is published on Clojars and the version shown in the README follows the latest version on the Clojars badge.

### Which HTTP client does cljs-ajax use in the browser and on the JVM?

In ClojureScript it wraps goog.net.XhrIo or js/XmlHttpRequest, and on the JVM it wraps the Apache HttpAsyncClient library.

### What request formats does cljs-ajax support?

Four: :transit, :json, :text and :raw. The request body format defaults to :transit, and the :format option also sets the matching Content-Type header, while :response-format controls how the response is parsed.

### What is the difference between the :java and :rails vec-strategy in cljs-ajax?

The setting decides how sequences are written into a GET query string. The :java strategy renders a vector as repeated keys such as a=1&a=2, while the :rails strategy renders bracketed keys such as a[]=1&a[]=2, and the README states that the rails form is the correct setting for working with HTTP.

## Sources

- [Issues](https://github.com/JulianBirch/cljs-ajax/issues)
- [JulianBirch/cljs-ajax on GitHub](https://github.com/JulianBirch/cljs-ajax)
- [README](https://github.com/JulianBirch/cljs-ajax/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/julianbirch-cljs-ajax
