DEV Community

Alex M
Alex M

Posted on

Lit vs Symbiote.js

Hi, DEV!

If you work with Web Components, you've probably heard of Lit. It's the most popular library in this space, maintained by the Google team, and it does its job well. So why would I write an article about an alternative?

Because Symbiote.js takes some fundamentally different architectural decisions - and even if you personally never plan to use it, some of these approaches are genuinely interesting and might change how you think about component design. I'm Alex, the maintainer of Symbiote.js, and I want to walk you through the key differences, being as fair as possible to both sides.

Lit and Symbiote.js both produce standard Custom Elements. They can coexist on the same page - alongside raw native web components - without any conflicts. This isn't "pick one forever"; you could literally use both in the same project.

At a Glance

Symbiote.js 3.x Lit 3.x
Core size (brotli) ~5.9 kb ~5.1 kb
Dependencies 0 runtime 0 runtime
Shadow DOM Opt-in Default
SSR Built-in Experimental (@lit-labs/ssr)
Routing Built-in Not included
State management Built-in Separate (@lit/context)
Build step Not required Not required

Both are lightweight. Lit's core is ~1 kb smaller, but Symbiote's 5.9 kb already includes state management, list rendering (Itemize API), computed properties, and exit animations - things that in Lit require additional packages.

Templates: the Biggest Difference

This is where the two libraries diverge most. And honestly, this is the part I find most interesting from an architectural standpoint.

What Lit Does

In Lit, templates are JavaScript expressions bound to this:

render() {
  return html`
    <p>${this.message}</p>
    <button @click=${this.onClick}>Click</button>
  `;
}
Enter fullscreen mode Exit fullscreen mode

The html tag returns a TemplateResult - a special object processed by Lit's rendering pipeline. Templates live inside render(), they reference this, and they re-evaluate on every update cycle.

What Symbiote Does Differently

Symbiote templates are plain HTML strings. They don't reference this at all:

MyComponent.template = html`
  <p>{{message}}</p>
  <button ${{onclick: 'onClick'}}>Click</button>
`;
Enter fullscreen mode Exit fullscreen mode

The html function produces an actual HTML string with bind= attributes. It's standard template literal syntax - no special objects, no rendering pipeline.

Why does this matter?

Templates become portable. Since a template is just an HTML string, it can live in a separate file, arrive from an API, or sit in the HTML document itself. The same component can even swap templates at runtime via use-template:

<my-component use-template="#compact-view"></my-component>
<my-component use-template="#detailed-view"></my-component>
Enter fullscreen mode Exit fullscreen mode

Templates can be pure HTML with zero JavaScript:

<div bind="textContent: myProp"></div>
<button bind="onclick: handler; @hidden: !flag">Click me</button>
Enter fullscreen mode Exit fullscreen mode

This bind attribute is all you need. A CMS editor, a no-code tool, or a server-side template engine can generate this markup - the component will find the bindings and wire everything up. The template is not coupled to any execution context.

Server-Side Rendering: One Module vs Three Packages

This is another area where the approaches differ significantly.

Lit SSR

Lit's SSR lives in @lit-labs/ssr (experimental). To get it working, you need:

  • @lit-labs/ssr - the server renderer
  • @lit-labs/ssr-client - client-side hydration support
  • @lit-labs/ssr-dom-shim - DOM polyfills for Node.js

And there are rules: lit-element-hydrate-support.js must load before the lit module. Server and client renders must produce identical output - mismatches cause errors. Lit SSR doesn't handle async work natively. The output contains <!--lit-part--> comment markers for template re-association.

Symbiote SSR

One module. No hydration mismatches. No comment markers:

import { SSR } from '@symbiotejs/symbiote/node/SSR.js';

await SSR.init();
await import('./my-app.js');

let html = await SSR.processHtml('<my-app></my-app>');
SSR.destroy();
Enter fullscreen mode Exit fullscreen mode

The trick is architectural: the server writes bind= attributes into the HTML (clean, standard HTML - no framework markers). The client reads those attributes and attaches reactivity. There's no diffing step, so there's nothing to mismatch. It's impossible by design.

Set isoMode = true on a component and it figures out what to do: if server content exists, hydrate; if not, render from template. One flag, no "use client" directives, no conditional logic:

class MyComponent extends Symbiote {
  isoMode = true;
  count = 0;
  increment() { this.$.count++; }
}

MyComponent.template = html`
  <h2 ${{textContent: 'count'}}>0</h2>
  <button ${{onclick: 'increment'}}>Click me!</button>
`;
Enter fullscreen mode Exit fullscreen mode

This exact code runs on the server and the client. Streaming is supported too via SSR.renderToStream().

Data Management: Decorators vs Prefix Tokens

The Lit Way

Lit uses decorators for reactive properties:

class MyEl extends LitElement {
  @property() name = '';
  @state() _count = 0;
}
Enter fullscreen mode Exit fullscreen mode

For data sharing across components, there's @lit/context - a separate package implementing the W3C Context Community Protocol:

// Provider
@provide({context: myContext})
@property() data = {};

// Consumer  
@consume({context: myContext})
@property() data;
Enter fullscreen mode Exit fullscreen mode

For anything global, you bring your own state management (Redux, MobX, signals, etc.).

The Symbiote Way

Symbiote has a built-in layered data context system. Each layer is identified by a prefix token in the template:

Token Context Example
(none) Local state {{count}}
^ Parent, pop-up (DOM tree walk) {{^parentTitle}}
* Shared (by ctx attr) {{*sharedCount}}
APP/ Named global {{APP/user}}
-- CSS custom property {{--label}}
+ Computed '+sum': () => ...

Here's what I find genuinely elegant about this: the template is the wiring. A component can bind to an external data context without a single line of component logic - just a prefix in the template. No decorator setup, no provider/consumer classes, no subscription boilerplate. Write {{APP/user}} and it's connected. Write {{^parentAction}} and it walks up the DOM to find the handler.

For example, shared context between components:

<upload-btn ctx="gallery"></upload-btn>
<file-list  ctx="gallery"></file-list>
<status-bar ctx="gallery"></status-bar>
Enter fullscreen mode Exit fullscreen mode
class UploadBtn extends Symbiote {
  init$ = { '*files': [] }
  onUpload(newFile) {
    this.$['*files'] = [...this.$['*files'], newFile];
  }
}

class FileList extends Symbiote {
  init$ = { '*files': [] }
}
Enter fullscreen mode Exit fullscreen mode

Three components, one shared *files state. No parent orchestrator, no event bus, no prop drilling. Just ctx="gallery" in the markup.

Shadow DOM: Default vs Opt-In

Lit uses Shadow DOM by default. Every component gets style isolation. To opt out, you override createRenderRoot() { return this; } - it works, but it's clearly an escape hatch.

Symbiote flips this: Light DOM is the default. Shadow DOM is opt-in, per component. Set renderShadow = true or define shadowStyles - shadow root is created. Don't - and your component renders in Light DOM.

This is more than a preference. For widgets embedded in third-party pages, mandatory Shadow DOM means the host application can't restyle your component - even when they need to. An opt-in model lets you choose isolation where it's valuable and openness where it's not.

Symbiote supports both simultaneously on the same component: rootStyles for Light DOM and shadowStyles for Shadow DOM.

CSS as a Data Source

This is probably the most unusual Symbiote feature. Components can read CSS custom properties directly into their reactive state:

my-widget {
  --label: 'Upload files';
}

@media (max-width: 768px) {
  my-widget {
    --label: 'Upload';
  }
}
Enter fullscreen mode Exit fullscreen mode
MyWidget.template = html`
  <button>{{--label}}</button>
`;
Enter fullscreen mode Exit fullscreen mode

CSS custom properties are read once on initialization - they set the starting state. Since browsers don't provide a universal signal for custom property changes, runtime updates require calling this.updateCssData() explicitly (for example, from a ResizeObserver or after toggling a class). It's not fully automatic, but the initialization alone is already powerful: different CSS classes, media queries, or host stylesheets can set different starting configurations for the same component - all without JavaScript.

You can even assign shared context groups via CSS:

.gallery-section { --ctx: gallery; }
Enter fullscreen mode Exit fullscreen mode

Lit supports CSS custom properties crossing shadow boundaries, but it doesn't have a mechanism to use CSS values as component state.

Build Tooling

Both libraries technically work without a build step. But in practice, Lit's developer experience is built around TypeScript decorators (@property(), @state(), @customElement()). The docs, the examples, most community code - all assume a TypeScript compiler.

Symbiote uses standard JavaScript throughout - ESM, class fields, template literals. No decorators, no transpilation. It supports importmap-based dependency sharing with CDN imports:

<script type="importmap">
{
  "imports": {
    "@symbiotejs/symbiote": "https://esm.run/@symbiotejs/symbiote"
  }
}
</script>
<script type="module" src="./my-app.js"></script>
Enter fullscreen mode Exit fullscreen mode

No bundler, no node_modules, works in the browser directly. When you need a bundler for production - esbuild, Rollup, or any standard tool will do.

What Lit Does Better

I said I'd be fair, so:

  • Community and ecosystem. Lit has a large, active community, extensive documentation, and wide adoption. Google uses it internally. If you need Stack Overflow answers and community plugins, Lit has far more momentum.
  • Maturity. Lit has been around longer (through Polymer → LitElement → Lit transitions) and has gone through more production battle-testing.
  • Slightly smaller core. ~5.1 kb vs ~5.9 kb - though the practical difference evaporates once you add context, lists, and routing to a Lit project.
  • Slightly faster initial render. Symbiote needs to look up the DOM environment after a component connects - reading context from its position in the tree, resolving pop-up bindings, checking CSS data. This makes its initial render a bit slower than Lit's. The gap is not dramatic, but it's noticeable in benchmarks. Once rendered, both libraries perform comparably for updates.

Developer Experience

Both libraries use html tagged template literals, so standard IDE syntax highlighting for HTML-in-JS works equally well in both. Lit has a dedicated VS Code plugin (lit-plugin) that adds type checking and completion inside templates.

Symbiote takes a different approach: instead of IDE tooling, it ships with a built-in devMode runtime messaging system. Enable it and you get warnings about broken bindings, missing context properties, and type mismatches - directly in the console, at the moment they occur. It's not an IDE plugin, but it catches the same class of problems at runtime.

Should You Try It?

If you're building widgets that embed into any environment, micro-frontends without framework coupling, or component libraries that need to work in React, Angular, Vue, and plain HTML - Symbiote's architectural choices solve real problems.

If you're building a single-stack application where all components live in one codebase and you want maximum community support, Lit is a perfectly solid choice.

And remember: they produce standard Custom Elements. You can mix Lit, Symbiote, and raw native web components on the same page without any conflicts. The choice isn't exclusive.

Even if you don't plan to use Symbiote.js right now, some of the ideas - templates as portable HTML strings, one-prefix data binding across contexts, CSS as a data source - are worth knowing about. Different approaches expand how we think about component architecture. And if any of this was interesting, a ⭐ on GitHub really helps us Open Source developers keep going.


Links:

Top comments (0)