@napi-rs/canvas: Skia in Node with no system packages, and the build that makes that true
High performance Skia canvas implementation. Zero system dependencies.
At a glance
- What is it?
- This is a Skia binding for Node that installs as a prebuilt binary with nothing else to install on the machine, and the claim on the tin is backed by a benchmark table and a hardware disclosure rather than by adjectives. The interesting parts are the machinery around the claim: eleven compiled targets, no install script at all, a font registry that handles colour fonts, and a Lambda deployment that needs a community layer.
- Who is it for?
- Reach for this package when your server has to draw or convert images and you do not want a graphics toolchain on the machine, because the whole point is that a clean container with a Node runtime can render and encode without installing anything, and the prebuilt targets cover the platforms that matter including Alpine-style musl builds and 64-bit RISC.
- 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 last received commits 3 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Zero system dependencies means Skia is built from source, and that is the cost model
The headline is a claim about your machine rather than about the package. It installs with either package manager:
yarn add @napi-rs/canvas
npm install @napi-rs/canvasNo system dependencies means that after installing this, you have not needed to install a graphics library, a font configuration database or a text layout engine, which is the normal tax on server-side image work in a language whose standard library has no rasteriser. The cost of that promise is paid by whoever builds the binary, and the repository shows the bill in full. There is a directory for Google's own Chromium build tooling, a directory for the Skia sources, a second directory that looks like a C shim layer, and CMake files. So the graphics stack is vendored and compiled rather than linked against the system, and the readme's version badge naming a specific Chromium revision is the fingerprint of a build that tracks upstream Skia. The build environment makes the same point. It starts from a cross-compilation image for a well-known Linux baseline, pins a specific assembler version and a specific major version of the compiler toolchain, sets the C and C++ compilers to that toolchain, points the linker at a sysroot, and asks for the C++ standard library from the same place. It installs Node from a distribution's setup script, a resource compiler, and the ninja build system, and then it deletes the shared C++ runtime and unwind libraries from the toolchain directory and symlinks alternatives instead, so the produced binary carries what it needs. Separate images exist for the 64-bit ARM and the musl targets. So read the claim precisely: installing the package requires nothing; producing it requires a compiler toolchain, a vendored copy of a large graphics codebase, and a target matrix. That is the trade, and for a server that renders images on demand it is almost always the right one.
Eleven prebuilt targets, and a support matrix that documents fewer
The manifest carries a block listing the platforms a binary is published for, and there are eleven of them.
"targets": [
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"armv7-unknown-linux-gnueabihf",
"x86_64-unknown-linux-musl"
]They cover 64-bit Intel and ARM on Linux, on macOS and on Windows, plus 32-bit ARM Linux, plus musl builds for both Intel and ARM, plus Android on ARM, plus 64-bit RISC-V on Linux. That is a serious distribution matrix for a package that is not a compiler. Now compare it with the readme, whose support section documents three things. For 64-bit ARM it names a specific CPU core generation or newer on Linux, and all of Apple's M-series chips on macOS. For 32-bit ARM it names a different specific core generation or newer. And then it states a C library floor, explaining that because Skia relies on a particular application programming interface from the C library, you need at least that version on your system. The CPU generation floors are the kind of precision most packages omit, and naming them is a courtesy to anyone deploying to an ARM host. But the gap between the two lists is real: Windows, 64-bit Intel Linux, Android and RISC-V all have prebuilt binaries and no documented support statement, and the readme does not say whether that means untested, unsupported, or simply not written down yet. The musl entries also have no documentation, which is the more consequential omission of the two, because musl is the C library of Alpine-style distributions and a lot of container images use it. If you deploy on Alpine, the binary exists, the readme is silent, and the only way to find out is to install it. The architecture detail that explains the floor is in the Rust manifest, where the allocator is configured differently per target: a local thread-local dynamic mode on Linux, the same with an architecture optimisation disabled on ARM, and a collector skip on exit everywhere. That is what a graphics library does with memory, and it is also why the readme bothers with CPU generations.
No install script, which is both a security property and a deployment constraint
There is a detail in the manifest that says more about the design than any of the features. The publish hook disables install scripts before publishing, and a post-publish hook re-enables them afterwards. The tool being used is the standard one for that job, and what it means is that the published package contains no install script at all. For a native module that is unusual, because the conventional approach is a post-install step that detects your platform and downloads the matching prebuilt binary, sometimes running a compile when there is no match. This package does none of that. The binary is resolved by the Node-API loader at require time from a file that is already in the tarball, which means no code executes during installation. That has two consequences and both are worth having. The first is that installation works on hosts and package managers where install scripts are blocked or disabled by policy, which increasingly includes hardened production images and some managed platforms. The second is that there is no window during which a compromised registry or a tampered download script could run as your user. The published file list is seven entries, all of them small JavaScript or type declaration files, which tells you the native binary is not counted in that list because it is selected per platform. The published Node floor, though, is where the manifest is optimistic. It declares support for a Node version from the long-distant past, which is defensible for the runtime because the Node-API interface it uses is version-stable, but the readme's own example imports the filesystem and path modules with the modern prefix that older runtimes do not have, and the development scripts register a TypeScript loader through a flag that arrived in a much newer release. The floor is a real number for running the library and not a real number for working on it, and anyone extending this package should read the toolchain file rather than the manifest on that point.
The Lambda path needs a community layer and a bundler exclusion
Serverless deployment is the one place the readme gives you a specific instruction rather than a general one, and both halves of the instruction are non-obvious. First, you cannot use the published package directly on Lambda; you need a layer, and the readme points you at a repository maintained by somebody else to get the layer's identifier. That is an unusual dependency for a package to have and it is worth being honest about: the serverless path runs through a third party's build of the native binary, maintained outside this repository, and if that project lags behind a release you have a version skew you did not choose. Second, and this is the instruction that saves an afternoon, you must exclude the package from your bundling step. The reason follows from the previous section. A bundler that traces your requires, finds this package, and tries to include its native binary in the deployment archive produces a bundle that either misses the platform-matched binary or ships the wrong one, and the failure appears at runtime as a module that cannot be loaded rather than at build time as a clear error. Excluding it lets the layer provide the binary at runtime instead, which is exactly what a layer is for. The same reasoning explains why there is no install script: nothing needs to happen at install time, so nothing has to be excluded and re-provided during a build. If you are deploying to Lambda, do both of these things in the order the readme gives them, and check the layer's own version notes against the package version you are pinning.
Registering a font by path, and colour fonts as a first class case
Text rendering on a server is where a canvas library either works or does not, and this one has a font registry as a named feature. The example registers fonts by filesystem path with a family name you choose, then lists the families the registry now knows about, then sets a font on the drawing context and draws with it.
GlobalFonts.registerFromPath(join(__dirname, '..', 'fonts', '[email protected]'), 'Apple Emoji')
GlobalFonts.registerFromPath(join(__dirname, '..', '__test__', 'fonts', 'COLRv1.ttf'), 'COLRv1')Two of the registrations in the example are worth attention. One loads a colour emoji font at twice the normal size and draws a row of emoji with a stroke, and the other loads a font in the newer colour font format and draws ordinary letters, which is the demonstration that colour glyphs work in text rather than only as pictures. That matters for anyone rendering user-supplied text server-side, because the older bitmap emoji format and the newer colour format are different problems and a library that handles only the first produces monochrome or broken output on modern emoji. The example also teaches the licensing position without saying so. It points at a path to a specific vendor's colour emoji font file, and the top-level file list has no fonts directory, so the font you use is one you supply, which means its licence is your problem and not the package author's. That is the right design for a library, and it is the right thing for you to know before you ship a server that renders whatever text arrives. The registry also exposes its contents, so an application can check at startup that the font it depends on actually registered, and fail loudly rather than rendering boxes.
Two ways to encode, and one of them runs on the thread pool
The usage example draws a house with a stroke rectangle, a filled rectangle for a door and a three-point path for a roof, then loads two images, one from disk and one from a URL, draws them, and encodes the result. Two API details in those few lines matter more than the drawing does. The first is that the encoder is asynchronous and returns the image data, and the comment in the example says where it runs: in the platform's thread pool, and that it is non-blocking. That is a real property for a server.
const pngData = await canvas.encode('png')
await promises.writeFile(join(__dirname, 'simple.png'), pngData)Canvas rasterisation is synchronous and unavoidable, since it is your own CPU time, but PNG encoding is a comparatively expensive, purely CPU-bound step that has no business sitting on your event loop while it runs. So the async encode moves it off the loop and lets other requests proceed. The format list is given in the same comment, and it is broader than the one most canvas libraries manage, covering JPEG, AVIF and WebP alongside PNG. The Rust manifest backs that list: the format-detection libraries and the AVIF encoder with its codec dependency are all direct dependencies, and a GIF encoder is there too. The second detail is that a synchronous buffer method also exists, and the emoji example uses it. That is the right escape hatch for a command line tool or a build script where nothing else is running and the simplicity is worth the block, and it is the wrong choice inside a request handler. The image loader is likewise asynchronous and accepts both a filesystem path and a URL, which means a server can fetch and composite remote imagery without a second dependency for HTTP.
The benchmark, what it measures, and why the sample counts are small
The performance section is unusually honest, and it is worth reading closely rather than just quoting. It publishes the benchmark code, then a hardware disclosure block: the machine identifier, the kernel, the operating system version, the exact chip and core count, the display configuration, the shell and terminal, and even the battery percentage, which is the sort of detail that tells you this was a real session on a real laptop rather than a cloud runner. Then two results tables, each comparing three implementations: this package, another Skia-based canvas, and the widely used node-canvas library. Both tests cover drawing a shape or a gradient and exporting to PNG, and in both the ordering is the same. This package is fastest, the other Skia binding is slowest, and node-canvas sits between them. The absolute figures are in the mid teens of milliseconds per operation with a stated deviation, and the sample counts are in the low dozens, which is the detail to hold onto. Sixty-odd samples is enough to see a gap of this size and not enough to characterise the distribution, so treat the ranking as reliable and the numbers as indicative. The second detail to hold onto is what the measured region contains. Both test names include the export, so these are pipeline measurements of drawing plus encoding rather than of drawing alone, and PNG encoding is not a trivial part of that pipeline. So the numbers tell you which package completes the whole job fastest on that machine, which is the question most people actually have, and they do not tell you which has the fastest rasteriser. The comparison against node-canvas is the interesting one for anyone choosing, because that library needs system packages to install and this one does not, so the benchmark and the dependency story point the same way.
Three version numbers, one binary name, and a licensing position that is simple
The naming in this project is worth mapping before you file anything, because there are three different versions and a binary name that matches none of them. The package on the registry is scoped and named one way, at version one point oh point nine. The GitHub repository belongs to a person and is called canvas. The Rust crate in the manifest is called canvas too, at version zero point one, and it is built as a dynamic library rather than published for others to depend on, so that version number is internal. The releases list contains a tag that looks like a Skia revision rather than a package version, which is how the project marks a build against a particular upstream graphics revision, and the readme badge names the Chromium revision the vendored Skia corresponds to. The native binary itself has a fourth name, and it is not the package name. None of this is a problem, and all of it will cost you a confused search if you go looking for the source by the package name. The licensing position is the simple part: the manifest declares the same permissive licence the badge advertises, there is a licence file, and there is a security policy, so there is nothing to reason about before depending on it. The documentation is duplicated in two languages with the Chinese version alongside the English one, which is a small signal about the project's centre of gravity, and the contributing guide and changelog are both present. The engineering conventions in the tree are also worth a glance if you are contributing: a formatter that runs three tools in parallel for the three file types in the repository, a linter that is not the one the stale ignore file suggests, a Rust toolchain pinned in its own file, and an ignore file for the blame annotation so a bulk reformat does not destroy the history of every line it touched.
Editorial conclusion
Reach for this package when your server has to draw or convert images and you do not want a graphics toolchain on the machine, because the whole point is that a clean container with a Node runtime can render and encode without installing anything, and the prebuilt targets cover the platforms that matter including Alpine-style musl builds and 64-bit RISC. Do not reach for it if you need a platform the published binaries do not cover, since the support matrix in the readme documents fewer platforms than the build matrix produces, and a source build of the whole graphics stack is not an afternoon. Two things to check before you commit. Your declared Node floor, because the manifest claims a very old version while the examples and the development scripts need a much newer one. And your serverless path, because on Lambda the package has to arrive as a layer from a separate community repository and has to be excluded from your bundler, which is a specific instruction rather than a general caution. Everything else is ordinary: a permissive licence, a published benchmark you can read rather than take on faith, and an encode call that runs on the platform's thread pool instead of blocking your event loop.
Frequently asked questions
What does zero system dependencies actually mean here?
It means the installed package brings its own graphics stack, so you do not install a graphics library, font database or text layout engine on the machine. The build side is the opposite: the repository vendors the Skia sources and Google's build tooling, pins a compiler toolchain, and ships separate images for the ARM and musl targets, so producing the binaries is a serious undertaking even though installing one is a single command.
Which platforms are supported?
The manifest publishes binaries for eleven targets including 64-bit Intel and ARM on Linux, macOS and Windows, 32-bit ARM Linux, musl builds, Android and 64-bit RISC-V. The readme's support section documents fewer, naming CPU generation floors for the two ARM variants and a minimum C library version, and it does not cover Windows, Intel Linux, Android, RISC-V or musl.
Does installing the package run any code?
No. The publish hooks disable install scripts around publication, so the published package has no install script, and the native binary is selected by the Node-API loader at require time from a file already in the tarball. That is why installation works where install scripts are blocked, and it is also why you must exclude the package from bundling when deploying to Lambda.
How do I render text and emoji?
Register a font by filesystem path with a family name, list the registered families to confirm, then set that family on the drawing context. The example registers a colour emoji font at double size and a font in the newer colour font format, so colour glyphs are supported in text. The font file is one you supply, so its licence is your responsibility.
How do I get PNG output without blocking my event loop?
Use the asynchronous encode call, which runs the encoding on the platform's thread pool and is documented as non-blocking, and then write the returned data. A synchronous buffer method also exists for command line tools. PNG, JPEG, AVIF and WebP are supported for encoding.
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/brooooooklyn-canvas)