keras-js manual
keras-js is the Keras 3 API for JavaScript. It runs on jax-js, which compiles array programs to WebGPU and WebAssembly kernels, the way Keras 3 runs on JAX in Python. This manual covers models, layers, training, saving, and how keras-js differs from Python Keras.
Getting started
Install
npm install @lamng3/keras-js @jax-js/jax
npm install h5wasm fflate # optional: .keras and .weights.h5 files
Without a bundler, use an import map:
<script type="importmap">
{
"imports": {
"@jax-js/jax": "https://cdn.jsdelivr.net/npm/@jax-js/jax@0.1.27/dist/index.js",
"@lamng3/keras-js": "https://lamng3.com/keras-js/keras-js.js",
"h5wasm": "https://esm.sh/h5wasm@0.10.3",
"fflate": "https://esm.sh/fflate@0.8.3"
}
}
</script>
<script type="module">
import * as keras from "@lamng3/keras-js";
console.log(await keras.init()); // "webgpu" or "wasm"
</script>
First model
import * as keras from "@lamng3/keras-js";
await keras.init(); // WebGPU when available, else WebAssembly
const model = new keras.Sequential([
keras.Input([784]),
new keras.layers.Dense(128, { activation: "relu" }),
new keras.layers.Dropout(0.2),
new keras.layers.Dense(10),
]);
model.compile({
optimizer: "adam",
loss: new keras.losses.SparseCategoricalCrossentropy({ fromLogits: true }),
metrics: ["accuracy"],
});
const history = await model.fit(xTrain, yTrain, { epochs: 5, batchSize: 64, validationSplit: 0.1 });
const [loss, accuracy] = await model.evaluate(xTest, yTest);
const logits = model.predict(xTest);
x and y can be jax-js arrays, nested JS arrays, or typed arrays. The rest reads like Keras:
the argument names are the Keras names in camelCase (batch_size → batchSize).
Devices
keras.init() initializes jax-js and selects a device: WebGPU when the browser exposes
navigator.gpu, otherwise WebAssembly. Pass a device to force one:
await keras.init("wasm"); // "webgpu" | "wasm" | "webgl" | "cpu"
keras.backend.device(); // the active device
In Node, keras-js uses WebAssembly.
Arrays and ownership
keras-js computes with jax-js arrays (keras.ops is the jax-js NumPy API). JavaScript has no
destructors, so jax-js frees GPU memory by reference counting, and operations consume their
arguments. Use .ref to keep an array alive after passing it in:
import { numpy as np } from "@jax-js/jax";
const x = np.array([1, 2, 3]);
const y = x.ref.mul(2); // x stays alive
const z = x.add(1); // x is consumed here
The rules in keras-js:
| Call | Inputs |
|---|---|
model.fit, evaluate, predict, trainOnBatch |
borrowed: your arrays stay alive |
layer.apply(x) on arrays, layer.call(x) |
consumed, like any jax-js op |
variable.value, model.getWeights() |
return new references that you own |
variable.assign(v), model.setWeights(ws) |
take ownership of the new values |
A model keeps exactly one reference to each weight; the test suite checks that fit() leaks
nothing. See the jax-js README for the
full rules.
Models
Sequential
const model = new keras.Sequential([
keras.Input([28, 28, 1]),
new keras.layers.Conv2D(32, 3, { activation: "relu" }),
new keras.layers.MaxPooling2D(2),
new keras.layers.Flatten(),
new keras.layers.Dense(10),
]);
model.add(new keras.layers.Activation("softmax"));
model.summary();
Without keras.Input, the model builds on its first batch, as in Keras. model.layers excludes
the input layer; model.pop() removes the last layer.
Functional API
keras.Input() returns a symbolic KerasTensor. Calling a layer on symbolic tensors records the
graph. JavaScript objects are not callable, so layer(x) is written layer.apply(x):
const L = keras.layers;
const image = keras.Input([32, 32, 3], { name: "image" });
const meta = keras.Input([4], { name: "meta" });
let h = new L.Conv2D(16, 3, { activation: "relu" }).apply(image);
h = new L.GlobalAveragePooling2D().apply(h);
h = new L.Concatenate().apply([h, meta]);
const label = new L.Dense(10, { name: "label" }).apply(h);
const score = new L.Dense(1, { name: "score" }).apply(h);
const model = new keras.Model({ inputs: [image, meta], outputs: [label, score] });
Layers can be shared (apply one layer object twice and the weights are shared), and models nest
inside other models. model.getLayer(name) looks a layer up by name.
Subclassing
class Residual extends keras.Model {
inner = new keras.layers.Dense(64, { activation: "relu" });
outer = new keras.layers.Dense(64);
call(x, { training } = {}) {
return this.outer.apply(this.inner.apply(x.ref, { training })).add(x);
}
}
Layers stored on properties (or in arrays on properties) are tracked automatically, as Keras
tracks attributes. call follows the ownership rules: it receives its input and must consume it
exactly once, so x.ref is used for the first of two uses.
Layers
Constructors take Keras' required arguments positionally, then an options object:
new Dense(units, opts), new Conv2D(filters, kernelSize, opts), new Dropout(rate, opts),
new Embedding(inputDim, outputDim, opts), new MultiHeadAttention(numHeads, keyDim, opts),
new LSTM(units, opts). Every layer accepts name, trainable and dtype.
| Family | Layers |
|---|---|
| Core | Input, InputLayer, Dense, Activation, ReLU, LeakyReLU, Embedding (incl. maskZero), Masking, Identity, Lambda, Rescaling |
| Regularization | Dropout, GaussianNoise |
| Convolution | Conv1D (incl. "causal"), Conv2D, Conv3D, DepthwiseConv2D, SeparableConv2D, Conv2DTranspose |
| Pooling | MaxPooling1D/2D, AveragePooling1D/2D, GlobalAveragePooling1D/2D, GlobalMaxPooling1D/2D |
| Normalization | BatchNormalization, LayerNormalization (incl. rmsScaling) |
| Reshaping | Flatten, Reshape, Permute, RepeatVector, ZeroPadding2D, UpSampling2D |
| Merging | Add, Subtract, Multiply, Average, Maximum, Minimum, Concatenate |
| Recurrent | SimpleRNN, LSTM, GRU (both resetAfter modes), Bidirectional |
| Attention | MultiHeadAttention (self, cross, useCausalMask) |
Convolutions accept strides, padding ("valid", "same"), dilationRate, groups and
dataFormat ("channels_last" by default). Recurrent layers accept returnSequences,
returnState, goBackwards and zeroOutputForMask (see Recurrent states).
MultiHeadAttention takes its tensors as a list: mha.apply([query, value]),
mha.apply([query, value, key]), or mha.apply(x) for self-attention. Options such as
useCausalMask and attentionMask (a [B, T, S] boolean tensor) go in the second argument and
are saved with the graph.
Masking
Masks mark which timesteps are real and which are padding, as in Keras. A mask is created by
Embedding({ maskZero: true }) (token 0 is padding) or by Masking({ maskValue }) (a step whose
features all equal maskValue), and it flows through the model by itself:
const tokens = keras.Input([null], { dtype: "int32" });
let h = new L.Embedding(10_000, 64, { maskZero: true }).apply(tokens);
h = new L.LSTM(64, { returnSequences: true }).apply(h); // skips padded steps
const attn = new L.MultiHeadAttention(4, 16).apply([h, h]); // padded keys are never attended to
h = new L.GlobalAveragePooling1D().apply(new L.Add().apply([h, attn])); // averages real steps only
| Layers | What they do with a mask |
|---|---|
LSTM, GRU, SimpleRNN, Bidirectional |
skip masked steps: states carry over and the output repeats (or is zero with zeroOutputForMask); keep the mask when returnSequences |
MultiHeadAttention |
AND of query, value and key masks with useCausalMask and attentionMask; the output keeps the query mask |
GlobalAveragePooling1D |
averages unmasked steps |
BatchNormalization |
training statistics over unmasked steps |
Dense, Activation, Dropout, LayerNormalization, Rescaling, Identity, Lambda, GaussianNoise |
pass the mask through |
Add, Multiply, … / Concatenate |
OR of the masks when every input has one / Keras' concatenation rule |
| all other layers | drop the mask |
The loss uses the output mask: the mean runs over unmasked entries only. As in Keras 3.15 (JAX
backend), metrics given to compile() do not use the output mask; the parity tests check both
behaviors.
Eagerly, pass a mask with layer.apply(x, { mask }) and read one with
const [y, mask] = layer.applyWithMask(x, { mask }). A custom layer passes masks through by
setting supportsMasking = true, or computes its own by overriding
computeMask(inputs, mask), which returns a new mask the caller owns.
Recurrent states
returnState: true adds the final states to the output: [output, h], or [output, h, c] for
LSTM, and [merged, ...forwardStates, ...backwardStates] for Bidirectional. The
initialState option starts a layer from given states, which is how encoder-decoder models are
written:
const [, h, c] = new L.LSTM(128, { returnState: true }).apply(encoderInputs);
const decoded = new L.LSTM(128, { returnSequences: true }).apply(decoderInputs, { initialState: [h, c] });
Tensor options such as initialState and attentionMask are graph inputs: they are saved in
the config as Keras writes them (initial_state, attention_mask) and load in Python.
Activations and initializers
Activations are named as in Keras ("relu", "gelu", "silu", "softmax", "leaky_relu", …;
camelCase also works) or passed as functions. GELU is the exact (erf) form and leaky_relu uses a
slope of 0.2, matching Keras 3.
Initializers are strings ("glorot_uniform", "he_normal", "orthogonal", …) or objects:
new keras.initializers.RandomNormal({ stddev: 0.02, seed: 1 }). Seeds make initialization
reproducible; keras.utils.setRandomSeed(n) seeds everything else.
Custom layers
class Scale extends keras.Layer {
build(inputShape) {
this.w = this.addWeight({ name: "w", shape: [inputShape.at(-1)], initializer: "ones" });
}
call(x) {
return x.mul(this.w.value);
}
}
build runs once with the input shape. Create weights there with addWeight (it accepts
initializer, regularizer and trainable). In call, read a weight with .value, which
returns a fresh reference. Output shapes are inferred automatically by running the layer on
zeros; override computeOutputShape(inputShape) to skip that. To make the layer loadable from
saved files, implement getConfig() and register it with keras.saving.registerSerializable.
Training
compile
model.compile({
optimizer: new keras.optimizers.AdamW({ learningRate: 3e-4, weightDecay: 0.01, globalClipnorm: 1 }),
loss: "categorical_crossentropy", // or one loss per output: [loss1, loss2]
lossWeights: [1, 0.5],
metrics: ["accuracy"], // or one list per output
weightedMetrics: ["accuracy"], // these also see sampleWeight
jitCompile: true, // default
});
With jitCompile (the default), the train, evaluation and prediction steps are each compiled
once per input shape, so the first batch is slower than the rest.
fit, evaluate, predict
| Method | Returns |
|---|---|
await fit(x, y, { batchSize, epochs, validationSplit, validationData, shuffle, sampleWeight, classWeight, callbacks, verbose, initialEpoch, stepsPerEpoch }) |
a History |
await evaluate(x, y, { batchSize, sampleWeight, returnDict }) |
the loss, [loss, ...metrics], or a dict |
predict(x, { batchSize }) |
an array (a list for several outputs) |
await trainOnBatch(x, y, { sampleWeight, classWeight }), await testOnBatch(x, y, { sampleWeight }) |
batch logs |
For several inputs or outputs, pass lists in the order of inputs and outputs. Logs use Keras
names: loss, accuracy, val_loss, and for several outputs <output>_<metric>.
Sample and class weights follow Keras exactly. sampleWeight has one value per sample
([N]) or per timestep ([N, T], for sequence outputs), or one array per output; the loss
becomes sum(loss × weight) / count, so a weight of 0 removes an example and 2 counts it twice.
classWeight: { 0: 1, 1: 5 } turns class indices (or one-hot targets) into sample weights. In
validationData, a third element holds validation sample weights. Plain metrics ignore
weights; weightedMetrics use them and are logged with a weighted_ prefix when the name
collides, as in Keras.
await model.fit(x, y, { sampleWeight: confidence, classWeight: { 0: 1, 1: 10 }, epochs: 5 });
fit waits on the device every yieldEvery batches (default 8), which keeps a page responsive.
Callbacks that want per-batch logs set wantsBatchLogs, which syncs every batch.
Losses and metrics
Losses: MeanSquaredError, MeanAbsoluteError, MeanAbsolutePercentageError,
BinaryCrossentropy, CategoricalCrossentropy, SparseCategoricalCrossentropy (with
fromLogits and labelSmoothing), Huber, LogCosh, KLDivergence, CosineSimilarity,
Hinge, or their snake_case names ("mse", "sparse_categorical_crossentropy", …). Any
(yTrue, yPred) => perSample function also works.
Metrics: "accuracy" resolves to binary, categorical or sparse accuracy from the loss, as in
Keras. Also BinaryAccuracy, CategoricalAccuracy, SparseCategoricalAccuracy,
MeanSquaredError, MeanAbsoluteError, RootMeanSquaredError, Precision, Recall. Metrics
support the stateful API too: updateState, await result(), resetState.
Optimizers
SGD (momentum, Nesterov), Adam (AMSGrad), AdamW, Adamax, RMSprop (momentum, centered),
Adagrad. Every optimizer accepts clipnorm, clipvalue, globalClipnorm and weightDecay,
and the update rules follow Keras 3. Learning rates are numbers or schedules:
const lr = new keras.optimizers.CosineDecay(1e-3, 10_000, { warmupTarget: 3e-3, warmupSteps: 500 });
new keras.optimizers.Adam({ learningRate: lr });
Also ExponentialDecay, InverseTimeDecay and PiecewiseConstantDecay. Scalars such as the
learning rate are passed into the compiled step as arrays, so a schedule or callback can change
them without recompiling.
Callbacks
History (returned by fit), ProgbarLogger, EarlyStopping, ModelCheckpoint,
LearningRateScheduler, ReduceLROnPlateau, TerminateOnNaN, and LambdaCallback:
await model.fit(x, y, {
epochs: 100,
callbacks: [
new keras.callbacks.EarlyStopping({ monitor: "val_loss", patience: 5, restoreBestWeights: true }),
new keras.callbacks.LambdaCallback({ onEpochEnd: (epoch, logs) => chart.push(logs.loss) }),
],
});
Subclass keras.callbacks.Callback for your own. Hooks may be async; fit awaits them.
ModelCheckpoint(filepath, { monitor, saveBestOnly, saveWeightsOnly, mode, verbose, onSave })
saves a .keras file (or .weights.h5) at the end of each epoch, or only when monitor
improves. filepath may contain {epoch} or {epoch:02d}. Node writes the file; in every
runtime the latest save is in checkpoint.bytes, so keras.saving.loadModel(checkpoint.bytes)
restores the best model, and onSave(bytes, path) can store it elsewhere (IndexedDB, a
download).
Utilities
| Function | Keras |
|---|---|
keras.utils.toCategorical(labels, numClasses) |
to_categorical |
keras.utils.padSequences(seqs, { maxlen, padding, truncating, value, dtype }) |
pad_sequences (pads and truncates at the start by default) |
keras.utils.setRandomSeed(n) |
set_random_seed |
Regularization and freezing
kernelRegularizer: new keras.regularizers.L2(1e-4) (or "l1", "l2", L1L2) adds a penalty
to the loss. Setting layer.trainable = false freezes a layer; a frozen BatchNormalization runs
in inference mode, as in Keras, which is what fine-tuning needs:
const base = await keras.saving.loadModel(bytes);
base.trainable = false;
const model = new keras.Sequential([base, new keras.layers.Dense(5)]);
Custom training loops
Every model can run as a pure function of its weights. This is what fit does internally, and it
works with any jax-js transformation:
import { valueAndGrad } from "@jax-js/jax";
const vars = model.trainableWeights;
const opt = new keras.optimizers.Adam({ learningRate: 1e-3 });
const lossFn = (values, x, y) => {
const [[pred]] = model.statelessCall(values, model.nonTrainableWeights.map((v) => v.value), [x]);
return keras.ops.mean(keras.ops.square(pred.sub(y)));
};
const [loss, grads] = valueAndGrad(lossFn)(vars.map((v) => v.value), x.ref, y.ref);
opt.applyGradients(grads.map((g, i) => [g, vars[i]]));
statelessCall(trainable, nonTrainable, inputs, { training, key }) returns
[outputs, newNonTrainable, regularizationLoss]. For lower-level control,
new keras.StatelessScope(variables, values) binds variables to values while a function runs.
Saving and Python interop
keras-js uses Keras 3's file formats with the same weight layout, so files move both ways.
| Function | Format |
|---|---|
await saving.saveModel(model) / await saving.loadModel(bytes) |
.keras archive (config, weights, compile config, optimizer state) |
await saving.saveWeightsH5(model) / await saving.loadWeightsH5(model, bytes) |
.weights.h5 |
await saving.saveWeightsSafetensors(model) / saving.loadWeightsSafetensors(model, bytes) |
safetensors, keyed by Keras paths |
saving.modelToJSON(model) / saving.modelFromJSON(json) |
the model config only |
.keras and .h5 need the optional h5wasm and fflate packages, loaded on first use.
Safetensors and JSON need nothing.
Optimizer state. A compiled model's .keras file includes the optimizer's step count,
learning rate and slots (Adam's moments, RMSprop's velocities, …) in Keras' layout, and
loadModel restores them, so training resumes exactly where it stopped, whichever side saved
the file. Pass { includeOptimizer: false } to saveModel for a smaller file.
saving.loadOptimizerH5(model, bytes) restores the state into an already-compiled model.
Keras ops in graphs. Keras records how masks are computed as op nodes (NotEqual, Any,
LogicalOr, ExpandDims, …) in functional configs. keras-js replays these ops, along with basic
arithmetic and shape ops written with keras.ops. An op it does not know raises an error naming
it.
Python to the browser. Save in Python and load in JavaScript:
model.save("classifier.keras")
const bytes = new Uint8Array(await (await fetch("classifier.keras")).arrayBuffer());
const model = await keras.saving.loadModel(bytes); // compiled if it was saved compiled
const probs = model.predict(images);
Browser to Python. Save in JavaScript and load in Python:
const bytes = await keras.saving.saveModel(model);
// download it, upload it, or fs.writeFileSync("model.keras", bytes) in Node
model = keras.saving.load_model("model.keras")
Both directions are tested against Keras 3.15 (see Parity).
Examples from keras.io
Five examples from keras.io/examples are ported in
examples/keras-io/, line for
line, and run on the Examples page:
| Example | What it shows |
|---|---|
| Simple MNIST convnet | Sequential, Conv2D, MaxPooling2D, toCategorical, categorical_crossentropy |
| Timeseries classification from scratch | functional API, Conv1D, BatchNormalization, ReLU, ModelCheckpoint, ReduceLROnPlateau, EarlyStopping |
| Text classification with Transformer | custom layers holding MultiHeadAttention, LayerNormalization and a nested Sequential |
| Bidirectional LSTM on IMDB | Embedding, stacked Bidirectional(LSTM) |
| Character-level seq2seq | returnState, initialState, inference models built from trained layers |
Each file exports main({ epochs }). In Node:
npm run build
node examples/run.js text_classification_with_transformer --epochs 2
What changes in translation is what this manual describes: layer(x) is layer.apply(x),
custom layers pass { training } on to their sublayers, and arrays used twice take a .ref.
keras.datasets has no JavaScript counterpart; examples/datasets.js loads the same data (IMDB
already passed through pad_sequences, built by tools/make_example_data.py).
Performance
- WebGPU is the fast path. On WebAssembly, jax-js runs dense layers well, but convolutions are slow: a 16→32-channel 3×3 convolution on a batch of 128 takes about 240 ms in jax-js 0.1.27 in Node. Use WebGPU for CNNs.
- Keep shapes fixed. Each new input shape compiles a new program. The last, smaller batch of an epoch compiles once more.
- Batch logs cost a sync. Prefer epoch-level callbacks;
verbose: 1in Node and callbacks withwantsBatchLogssync every batch.
MNIST measurements on an Apple M2 Pro (batch 128, Adam with cosine decay from 1e-3, 10% of the 60,000 training images held out for validation, 10 epochs):
| Model | Runtime | Time | Test accuracy |
|---|---|---|---|
| MLP 784-512-256-10, dropout | Chrome 155, WebGPU | 16 s (33,800 samples/s) | 98.5% |
| MLP 784-512-256-10, dropout | Node 20, WebAssembly | 55 s (9,800 samples/s) | 98.4% |
| CNN: 2× (Conv2D + BatchNorm + MaxPooling2D), Dense 128 | Chrome 155, WebGPU | 174 s (3,100 samples/s) | 99.3% |
Reproduce with examples/mnist-node.ts and the MNIST page.
Differences from Python Keras
| Python Keras | keras-js |
|---|---|
layers.Dense(64, activation="relu") |
new layers.Dense(64, { activation: "relu" }) |
layer(x) |
layer.apply(x) |
keras.Model(inputs, outputs) |
new keras.Model({ inputs, outputs }) |
mha(query, value) |
mha.apply([query, value]) |
model.fit(...) blocks |
await model.fit(...) |
batch_size=32 |
batchSize: 32 (configs and files keep snake_case) |
| garbage-collected tensors | reference-counted arrays (see Ownership) |
Not yet supported: stateful RNNs, recurrent dropout, return_attention_scores, mixed
precision, tf.data-style datasets, and constraints. Subclassed models are not loadable from
config unless registered. The roadmap tracks
these.
Parity with Python Keras
npm test runs three suites:
- Forward parity. Nine models built and saved by Python Keras 3.15: an MLP with BatchNorm
and LayerNorm, a CNN that uses every conv and pooling variant, a causal Conv1D stack, an
Embedding/LSTM/GRU/Bidirectional/SimpleRNN stack, a transformer block with causal self-attention
and cross-attention, a multi-input, multi-output graph with shared layers, two masked models
(padded token sequences through LSTM, attention, BatchNorm, Bidirectional and pooling;
Maskingwith left, right and inner padding merged byConcatenate), and an encoder-decoder that usesreturn_stateandinitial_state. Each is loaded from its.kerasfile and must reproduce Keras' predictions to 2e-4. - Training parity. Five
train_on_batchsteps with SGD (Nesterov), Adam, AdamW, centered RMSprop, Adagrad, and AMSGrad with global clipping, with L2 regularization and BatchNorm. Losses must match to 1e-4 and every weight to 5e-4. Also: a masked per-timestep loss with temporal sample weights and weighted metrics,class_weightinfit(), and resuming four optimizers (Adam, centered RMSprop with momentum, AMSGrad with a learning rate schedule, SGD with momentum) from Keras checkpoints. - Python round trip. Models built and trained in keras-js are saved as
.kerasand loaded by Python Keras, which must reproduce keras-js' predictions; this includes a masked model, an encoder-decoder withinitialState, and a checkpoint whose optimizer state Python resumes. This suite runs whenKERAS_PYTHONpoints at a Python with Keras, JAX and safetensors.
Regenerate the fixtures with npm run parity:fixtures.