Tinybench keeps samples out of memory and aborted results in your table
🔎 A simple, tiny and lightweight benchmarking library!
At a glance
- What is it?
- A dependency-free TypeScript benchmarking library built on Web APIs. Its concurrency threshold limits iterations inside one task rather than tasks, async detection calls your function once before the run, a task can abort and still hand back statistics, and raw samples are discarded unless you ask.
- Who is it for?
- Tinybench is a good fit if you want timings with real statistics and no dependency tree, and you are willing to read the task result state before trusting a number. Read four things first.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 11, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Task names are map keys, so a duplicate replaces the first
Tasks are held by name in a `Map`, and the documentation is explicit that the task name should always be unique within an instance. That single sentence describes the whole storage model and its main hazard.
A map keyed by string means a second `add` with a name already in use writes over the first entry. There is no duplicate check documented and no error, so a loop that generates task names, or a copy-pasted `add` call, silently replaces a task rather than adding one. The task count you expect and the task count you measure are then different numbers, and nothing in the output says so.
The chaining shape encourages that mistake:
import { Bench } from 'tinybench'
const bench = new Bench({ name: 'simple benchmark', time: 100 })
bench
.add('faster task', () => {
console.log('I am faster')
})
.add('slower task', async () => {
await new Promise(resolve => setTimeout(resolve, 1)) // we wait 1ms :)
console.log('I am slower')
})
await bench.run()The `add` method returns a reference to the Bench instance, which is what makes the fluent style above work, and it also makes it easy to append work without looking back at what is already registered.
One more default is worth stating because it catches people out: the library does not log any result by default. You read statistics out of `bench.tasks` or another API after the run and format them yourself.
Async detection calls your function once before timing starts
A task function can be synchronous or asynchronous, and Tinybench works it out for you. The method is to check whether the provided function is an `AsyncFunction` or whether it returns a `Promise`, and establishing the second half of that requires calling the function once.
So your benchmark body executes at least once before any measurement, and once more per iteration afterwards. A body that appends to a shared array, increments a counter, writes a file, or hits a network endpoint will show that first call in your results, and if the body has a side effect that only happens once it may not happen again.
You can skip the detection by setting the `async` option when adding a task, and the guidance is specific about when:
const bench = new Bench()
bench.add('asyncTask', async () => {}, { async: true })
bench.add('syncTask', () => {}, { async: false })Set `async: false` only for a genuinely synchronous task. The documented alternative for the awkward middle case is to measure the synchronous cost of a function that returns a `Promise` and record it through `overriddenDuration` instead, which is a mechanism for supplying your own duration rather than having the timer derive one. There is a section on the page titled for task-supplied measurements that covers it further.
The warning event names three ways a measurement goes bad
Both `Task` and `Bench` extend `EventTarget`, so listeners attach with the ordinary `addEventListener` and `removeEventListener`. A bench-level listener sees every task's cycle; the same event name attached to a task fires only for that task.
One listener exists for something other than progress. The `warning` event runs when timer saturation is detected for a task's measured samples, and it carries a `reason` drawn from a closed set of three strings:
// runs when timer saturation is detected for a task's measured samples
bench.addEventListener('warning', (evt) => {
const task = evt.task;
const reason = evt.reason; // 'zero-dominated' | 'low-distinct' | 'zero-mad'
});The three names describe three different ways a distribution can be degenerate rather than merely noisy. Samples dominated by zeros means the work finished faster than the clock can resolve. Too few distinct values means the measurement barely varied. A zero median absolute deviation means the spread statistic the library reports collapsed to nothing.
This is the part of the design that matters for anyone publishing numbers. The library will tell you when its own timing has saturated instead of returning a confident-looking average of zeros, which is the failure mode that makes naive microbenchmarks misleading.
The threshold counts iterations inside a task, not tasks
Concurrency has three modes. The default is `null`, which disables it. `task` runs each task's iterations, meaning repeated calls of one task function, concurrently. `bench` runs different tasks within the bench concurrently, as concurrent cycles.
The `threshold` option is where the documentation earns its keep, because its name suggests it bounds tasks and it does not:
const bench = new Bench({
concurrency: 'task', // The concurrency mode to determine how tasks are run.
threshold: 10, // Maximum concurrent iterations within a task. Defaults to Infinity.
})
await bench.run()It limits concurrent iterations within that task, not concurrent benchmark tasks. With `task` mode and the default `Infinity`, iterations are bounded only by the time and iteration budgets, so a fast task can occupy the event loop with a great many in-flight calls at once.
There is a related landmine in the same paragraph. Disabling the iteration limit with `iterations: 0` requires a finite `threshold`, for example 10, rather than the default. Those two settings, `iterations: 0` and an untouched threshold, are the combination to avoid.
The same rules apply to `warmupTime` and `warmupIterations` in `warmup()`, with one exception: `Task.warmupSync()` always uses the sequential rules regardless of the configured concurrency, so a task can warm up differently from how it is then measured.
A task can abort and still hand you statistics
Every task result carries a `state`, and the values matter because the default table converter branches on them:
const state = task.result.state
return {
'Task name': task.name,
...(state === 'aborted-with-statistics' || state === 'completed'
? {
'Latency avg (ns)': `${formatNumber(mToNs(task.result.latency.mean))} \xb1 ${formatNumber(task.result.latency.rme)}%`Two states are treated as carrying numbers: `completed`, and `aborted-with-statistics`. The second name says plainly what happened. A task that was aborted can still have a latency distribution, and the library's own output helper deliberately includes those numbers rather than hiding them.
That is a defensible choice for an aborted-with-partial-data run, but it means the safe read is guarded. Code that reaches for `task.result.latency.mean` without checking `state` first will consume figures from a run that did not finish. The converter is the reference for how to do it: test the state, then read.
The table format itself is customizable by passing a convert function to `bench.table()`, typed as `ConsoleTableConverter`. The helpers `formatNumber` and `mToNs` are exported for it, with `mToNs` converting the millisecond figures the result carries into nanoseconds for display.
Samples are discarded by default while percentiles are advertised
Raw samples are not kept. The stated reason is to minimize memory usage, and it applies to both latency and throughput samples.
Retention is opt-in in two places. At the bench level, set the `retainSamples` option when constructing the instance:
const bench = new Bench({ retainSamples: true })Or per task, in the options passed to `add`. The stated uses for turning it on are plotting, custom analysis, and exporting results, all of which need the individual measurements rather than the summary statistics.
That setting sits in tension with the feature list at the top of the page, which advertises percentiles among the statistically analyzed latency and throughput values alongside standard deviation, margin of error and variance. Percentile figures require an ordered distribution to compute from, and the visible portion of the page does not say whether they are calculated during the run and kept as summaries while the raw samples are discarded, or whether retaining them is a precondition.
If your use is a percentile plot or a distribution export, set `retainSamples` before the run rather than discovering after it that the values you need were never kept. Memory is the stated reason for the default, so the trade is deliberate, not a bug.
The published package lives in a directory that only exists at publish time
The repository root manifest is not what npm receives. Publishing happens through a staging directory named in the manifest itself:
"publishConfig": {
"directory": "package"
}Three scripts bracket the operation. `prepublishOnly` builds, removes any existing `./package`, and runs `clean-publish` to assemble a clean copy there. `postpublish` removes `./package` again. So the directory is created, filled, published from, and deleted inside one release run, and never exists in a working tree.
That explains an otherwise odd detail: the manifest's `files` array names nothing but the built `dist` directory tree. It is describing the contents of the staging directory, not of the repository, where `dist` does not appear in the top-level entries at all.
The build itself is `tsdown`, configured in `tsdown.config.ts`, with `dev` running the same command in watch mode. Entry points are declared as a single export: `types` resolves to `./dist/index.d.ts` and `default` to `./dist/index.js`. The manifest is `"type": "module"`, and `main` and `module` both point at the same ESM file, so there is no CommonJS fallback declared.
Versions are bumped by `bumpp`, which commits, pushes and tags in one step, with `@commitlint/cli` and a `.commitlintrc.json` enforcing the message format that makes that safe.
Size limits, two Vitest configs, and type tests off-Node
A library whose pitch is that it is tiny has a budget file for it. `.size-limit.json` sits at the root, so the published size is checked rather than asserted.
The test setup is split in two. There is `vitest.config.ts` for `test` and `test:coverage`, and a separate `vitest.pretest.config.ts` driven by `ci:pretest`, which runs before the main suite. Two configurations means the pretest suite is not run by a bare `vitest run`.
Then there is the runtime claim. The page says benchmarks run in multiple JavaScript runtimes because everything is built on Web APIs. The mechanism for checking that is a second type-check script, `typecheck:workerd`, which builds and then runs `tsc` against `type-tests/workerd/tsconfig.json`, with `@cloudflare/workers-types` among the dev dependencies. Types are checked against a Cloudflare Workers runtime, not only against Node.
The surrounding tooling is unusually strict for a small package. Lint runs with `--max-warnings 0` over an explicit path list, and the typedoc script runs with `--treatWarningsAsErrors --emit none`, so documentation warnings fail the build without emitting files. Dependency updates are automated through `renovate.json`, git hooks through `simple-git-hooks` and `.nano-staged.json`, and the build requires Node 20 or newer, with Volta pinning 24.21.0.
Two files hint at a wider toolchain than npm alone. There is a `patches/` directory alongside the pnpm lockfile, which is where patched dependencies live, and a `bunfig.toml` for running under Bun.
Editorial conclusion
Tinybench is a good fit if you want timings with real statistics and no dependency tree, and you are willing to read the task result state before trusting a number. Read four things first. Task names are map keys, so keep them unique within a bench or you will overwrite an earlier task. Setting `concurrency: 'task'` with the default `threshold` of Infinity leaves iterations unscheduled-bound, and disabling the iteration limit with `iterations: 0` requires a finite threshold rather than that default. Async detection invokes your function once at add time, so give it a side-effect-free body or set the `async` option explicitly. And raw samples are dropped by default, so if you intend to plot or export, set `retainSamples` before you run rather than discovering the gap afterwards. The package needs Node 20 or newer, is built with tsdown, and is type-checked against Cloudflare Workers types, which is the mechanism behind its claim to work across JavaScript runtimes.
Frequently asked questions
Does Tinybench print results by default?
No. `tinybench` does not log any result by default. You extract the statistics from `bench.tasks` or another API after running the benchmark and process them yourself, or pass `bench.table()` to `console.table()`.
What happens if two Tinybench tasks share a name?
Tinybench stores tasks by name in a `Map`, so the documentation states that the task name should always be unique within an instance. The `add` method returns a reference to the Bench instance, which is what allows chained calls.
How does Tinybench decide whether a task is asynchronous?
It checks whether the provided function is an `AsyncFunction` or whether it returns a `Promise`, and establishing the second case means calling the function once. You can bypass that by setting the `async` option to `true` or `false` when adding the task.
What does the Tinybench concurrency threshold actually limit?
It limits concurrent iterations within a task, not concurrent benchmark tasks, and it defaults to Infinity. Disabling the iteration limit with `iterations: 0` therefore requires a finite threshold rather than the default.
Are Tinybench's raw latency samples kept?
Not by default, to minimize memory usage. Set `retainSamples: true` on the Bench instance or on an individual task to keep the raw samples for plotting, custom analysis, or exporting results.
Which Node.js version does Tinybench require?
The package manifest declares `node` `>=20.0.0` in its engines field. Volta pins 24.21.0 and pnpm 12.8.1 for development, and the package is published as ESM only.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/tinylibs-tinybench)