Thin client: the Datahike API against a server, no engine in the page

September 5, 2026 · View on GitHub

Status: Beta. Tested on Node.js and the JVM against the standalone server; the JavaScript browser client is the same TypeScript code over fetch.

The npm entry datahike/remote is the Datahike API with nothing behind it: every call is a request to a Datahike server, and connections, databases and entities are opaque handles that go back to the server as they came. The bundle carries no database engine, which is what makes it a fit for a web page that should not ship one: about 4 KiB gzipped, against 69 KiB for the former ClojureScript thin-client bundle and about 440 KiB for the full runtime.

This is the same mode the dthk binary and the Clojure client use: a configuration whose :remote-peer names the server. Three modes, one API, chosen by configuration:

modenpm entrydata livesqueries runwrites go
embeddeddatahikein the page or the Node processlocallylocally
replicadatahike/kabelin a local replica of a server databaselocallyto the server, replicated back
thindatahike/remoteon the serveron the serverto the server

See browser-replicas.md for the replica and http-routes.md for the server.

Use

import * as d from 'datahike/remote';

const config = {
  store: { backend: ':memory', id: d.randomUuid() },
  'schema-flexibility': ':read',
  'remote-peer': { backend: ':datahike-server', url: 'https://data.example.com', token: process.env.TOKEN }
};

await d.createDatabase(config);           // or an admin created it for you
const conn = await d.connect(config);
await d.transact(conn, [{ ':person/name': 'Ada' }]);
const db = await d.db(conn);
await d.q('[:find ?n :where [?e :person/name ?n]]', db);   // => [['Ada']]
await d.release(conn);

For JavaScript and TypeScript consumers this entry is a hand-written TypeScript client. connect, db and the rest return handles: pass them back, do not look inside. A transaction report is a JavaScript object whose db-after is such a handle. Values convert at the boundary the way the main entry converts them: keywords as ':name' strings, uuids made with d.uuid or d.randomUuid, results as plain arrays and objects. Every function returns a Promise. The functions are exactly the specification's remote-capable ones; remote/index.d.ts lists them. ClojureScript consumers instead require datahike.http.client from the Clojure artifact; that API and its listen implementation are unchanged.

Authentication is the HTTP API's: the shared token, or a JWT once the server has a :validator (http-routes.md). What a caller may do is the server's :authorize policy and the permission graph, the same as for every other client. Under the server, query functions resolve only to the curated safe set and what the server registered (http-routes.md).

On the wire

JSON in both directions, using the server's tagged arrays for keywords, symbols, sets, UUIDs, dates, datoms, connections, databases, entities and transaction reports. A read (q, pull, datoms, …) is a GET whose JSON argument vector travels base64url-encoded in the URL while the encoded bytes fit in 2 KiB, so the response is cacheable by URL and the server sends the cache headers its :cache configuration asks for; larger arguments go by POST with a JSON body, which is never cached. A write is always a POST. A database handle carries the snapshot's commit id, so a read against one is the same result for as long as the handle is used.

The TypeScript client keeps up to 128 snapshot-read results per client instance in an in-memory least-recently-used cache. configureCache({maxEntries}) changes that bound (zero disables it), and clearCache() empties it. The cache dies with the page and uses neither localStorage nor IndexedDB. Persistence across sessions is the browser's HTTP cache; offline reads are the replica's job, not this client's.

Change notification

A thin client can follow the connected database without carrying a replica. On the JVM and in ClojureScript, listen returns a key immediately and calls back with transaction-report maps; pass that key to unlisten:

(require '[datahike.http.client :as client])

(def listener
  (client/listen conn ::ui
    (fn [{:keys [db-after deleted error] :as report}]
      (cond
        error   (println "listen failed" error)
        deleted (println "database deleted")
        :else   (println (client/q '[:find ?e :where [?e]] db-after))))))

(client/unlisten conn listener)

In JavaScript the callback receives the corresponding plain JavaScript object:

const listener = d.listen(conn, report => {
  if (report.error) {
    console.error(`listen failed with HTTP ${report.status}`, report.error);
    return;
  }
  if (report.deleted) return;
  renderFrom(report['db-after']);
});

d.unlisten(conn, listener);

The first callback is a resync report with :resync true and a :db-after handle. Later transaction reports contain :db-after, :db-before nil, :tempids, and :commit-id. Reports of at most 500 datoms also contain :tx-data; larger reports instead contain :truncated true. A slow consumer can receive another resync report after the server coalesces changes. Database deletion produces the terminal :deleted true report.

The client reconnects after a stream error or clean disconnect, starting at 500 ms and doubling the delay up to 30 seconds. It sends the last observed commit id when reconnecting; the server resyncs when that id is no longer the head. unlisten aborts the open request and cancels reconnects. Deletion does not reconnect. HTTP 401, 403, and 404 responses are terminal too: the callback receives {:error <ex-info> :status <integer>} in Clojure or ClojureScript, or an object with error and status in JavaScript, and the listener stops.

What is not there

  • No offline operation: every call needs the server.
  • No streaming results: a query result arrives whole. Use :limit and :offset, or narrow the query.

Reference

ts-client/src/api.generated.ts is generated from the API specification and the hand-written transport and codec live beside it. ClojureScript consumers use datahike.http.client from the Clojure artifact, the same namespace the JVM client uses.