Glossary

Why Bluesky Facets byteStart byteEnd Count Bytes, Not Characters

Taras Shynkarenko
Taras Shynkarenko
Updated: 9 min read
Why Bluesky facets byteStart byteEnd count bytes, not charactersWhy Bluesky facets byteStart byteEnd count bytes, not characters

TL;DR, Quick Answer

9 min read

A facet is one entry in the facets array of an app.bsky.feed.post record. It has an index, which is a byteSlice of byteStart and byteEnd, and a features array holding a link uri, a mention did, or a tag string. The offsets count bytes of the UTF-8 encoded text, start inclusive, end exclusive. The lexicon itself warns that languages like JavaScript index strings by UTF-16, so indexOf returns the wrong number the moment your text contains an accent or an emoji, and the resulting post is still schema valid.

What are Bluesky facets, and what do byteStart and byteEnd measure?

Bluesky stores post text as a plain string with no markup and no automatic link detection, so Bluesky facets byteStart byteEnd offsets are the only thing telling a client which stretch of that string is a link, a mention, or a hashtag. The app.bsky.richtext.facet lexicon calls a facet an "Annotation of a sub-string within rich text." Two fields are required on every facet: index, which is a #byteSlice, and features, an array of annotations that apply to that slice.

Here is the shape, straight from the lexicon definition:

{
  "index": { "byteStart": 17, "byteEnd": 36 },
  "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
}

The byteSlice definition sets the counting rule and the boundary rule at once: "Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text."

FieldTypeConstraint in the lexiconWhat it means
index.byteStartintegerminimum: 0First byte of the annotated range, included
index.byteEndintegerminimum: 0Byte after the last annotated byte, excluded
featuresarrayunion of #mention, #link, #tagOne or more annotations on that byte range

Read the constraint column again. minimum: 0 is the only numeric rule on either offset. There is no upper bound tied to the length of your text, no rule that byteEnd must exceed byteStart, and no rule that the range has to land on a character boundary. That is why bad offsets do not produce an error.

Why does my Bluesky link post as plain text?

A link renders as plain text because the post record contains no facet whose byte range covers that URL. The URL sitting in the text field does nothing on its own. Bluesky clients do not scan post text for http://, they read the facets array and annotate exactly the byte ranges it names.

The failure is quiet in both directions. Omit the facet and you get plain text. Ship a facet with offsets that are off by three, and the client dutifully links whatever bytes you pointed at, which may be the tail of the previous word plus most of the URL. Either way com.atproto.repo.createRecord returns a normal success response, because a record with wrong offsets is still a schema-valid record.

The lexicon leaves several behaviours undefined, and this gap is worth stating plainly rather than guessing at. app.bsky.richtext.facet does not specify what a renderer must do when a byte range runs past the end of the text, when two facets overlap, or when a range splits a multi-byte character in half. It also sets no maxLength on the facets array in app.bsky.feed.post, while the sibling tags array in the same record is explicitly capped at maxLength: 8. The cap on tags is documented; the cap on facets, if any exists in practice, is not.

A developer typing code on a laptop, the kind of quick JavaScript fix that silently breaks byte offsets on non-ASCII text.

Why does JavaScript give the wrong byteStart?

JavaScript gives the wrong byteStart because String.prototype.indexOf and String.prototype.length count UTF-16 code units, and facets count UTF-8 bytes. The lexicon says so in a note that most people never read, because it lives inside a JSON file rather than in a tutorial:

NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.

For pure ASCII text the two systems agree, which is precisely what makes this bug ship. You test with Opening hours https://example.com, every offset matches, and the code goes to production. Then a post picks up an accented word or an emoji and the offsets slide.

Take this string:

Café 🥐 hours https://example.com

Three counting systems disagree on it:

Counting unitValue for this stringWhere Bluesky uses it
Graphemes32maxGraphemes: 300 on text in app.bsky.feed.post
UTF-16 code units33Nothing. This is what text.length returns in JavaScript
UTF-8 bytes36byteStart and byteEnd, plus maxLength: 3000 on text

The divergence comes from two characters. é is one UTF-16 code unit and two UTF-8 bytes. 🥐 is a surrogate pair, so two UTF-16 code units, and four UTF-8 bytes. By the time you reach the URL, the two index systems are three apart:

PrefixUTF-16 code unitsUTF-8 bytes
Café 56
Café 🥐 811
Café 🥐 hours 1417

So text.indexOf('https://example.com') returns 14, and the correct byteStart is 17. Use the JavaScript number and Bluesky annotates bytes 14 through 32, which decode to rs https://example. The word "hours" loses its last two letters to the link, the final com falls outside it, and nobody gets an error.

AdaptlyPost
AdaptlyPost

Start 7-Day FREE Trial

All-platform analytics

Social Inbox

AI-powered assistant

How a Bad Offset Slips Through
Text has accents or emoji
indexOf counts UTF-16, not bytes
byteStart lands on the wrong byte
Record still passes schema validation
Link points at the wrong text
Nothing in this chain raises an error, so the wrong link ships silently.

How do I calculate byteStart and byteEnd correctly?

Encode the text to UTF-8 bytes with TextEncoder, then derive both offsets from encoded lengths rather than string indexes. Encoding the prefix before a match gives you byteStart; encoding the match itself gives you the length to add for byteEnd.

This runs as-is on Node 18 or later and in any modern browser:

const encoder = new TextEncoder();
const decoder = new TextDecoder();
 
function detectFacets(text) {
  const bytes = encoder.encode(text);
  const facets = [];
 
  const add = (match, feature) => {
    const byteStart = encoder.encode(text.slice(0, match.index)).length;
    const byteEnd = byteStart + encoder.encode(match[0]).length;
    if (decoder.decode(bytes.slice(byteStart, byteEnd)) !== match[0]) {
      throw new Error(`byte range ${byteStart}..${byteEnd} does not cover "${match[0]}"`);
    }
    facets.push({ index: { byteStart, byteEnd }, features: [feature] });
  };
 
  for (const m of text.matchAll(/https?:\/\/[^\s]+/g)) {
    add(m, { $type: 'app.bsky.richtext.facet#link', uri: m[0] });
  }
  for (const m of text.matchAll(/#([A-Za-z0-9_]+)/g)) {
    add(m, { $type: 'app.bsky.richtext.facet#tag', tag: m[1] });
  }
 
  return facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
}
 
console.log(JSON.stringify(detectFacets('Café 🥐 hours https://example.com #bakery'), null, 2));

Output:

[
  {
    "index": { "byteStart": 17, "byteEnd": 36 },
    "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
  },
  {
    "index": { "byteStart": 37, "byteEnd": 44 },
    "features": [{ "$type": "app.bsky.richtext.facet#tag", "tag": "bakery" }]
  }
]

The guard clause in add is the part worth keeping. It slices the encoded byte array back out, decodes it, and compares it to the text that was matched. Since the protocol will never tell you the range was wrong, this assertion is the only failure signal you get, and it costs one comparison per facet.

Two details in that snippet come from the lexicon rather than from taste. The tag feature carries bakery, not #bakery, because the lexicon says the reference "should not" include the prefix. And features is an array, so a single byte range can carry more than one annotation.

What goes inside the features array?

Each entry in features is one of three union members defined in the same lexicon file, distinguished by its $type string.

Feature$typeRequired fieldConstraintLexicon wording
Mentionapp.bsky.richtext.facet#mentiondidformat: did"The text is usually a handle, including a '@' prefix, but the facet reference is a DID."
Linkapp.bsky.richtext.facet#linkuriformat: uri"The text URL may have been simplified or truncated, but the facet reference should be a complete URL."
Tagapp.bsky.richtext.facet#tagtagmaxLength: 640, maxGraphemes: 64"The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags')."

Mentions carry the extra step. The visible text is a handle such as @example.com, but the facet stores a DID, so you resolve the handle first with the com.atproto.identity.resolveHandle query, which takes a handle parameter and returns a did. Its one documented error is HandleNotFound, described as "The resolution process confirmed that the handle does not resolve to any DID." Storing a DID rather than a handle is what keeps an old mention pointing at the right account after that account renames itself.

The link wording matters for display text. Because the facet holds the complete URL while the text may be truncated, you can show example.com/very-long... in the post and still send people to the full address, as long as the byte range covers the shortened text you actually wrote.

Why do older code samples use start and end instead?

Older samples use start and end because the post record used to carry a different field with different counting rules, and both versions still sit in the lexicon files today. app.bsky.feed.post defines an entities array marked "DEPRECATED: replaced by app.bsky.richtext.facet." Its index type is #textSlice, whose own description reads: "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings."

So the protocol shipped a UTF-16 slice type, replaced it with a UTF-8 one, and kept both definitions in the schema. Any snippet you find that builds { start, end } is targeting the deprecated field, and copying its index arithmetic into a byteSlice reproduces exactly the bug the newer NOTE warns about. Check the field names before you trust the offsets: byteStart and byteEnd mean bytes, start and end meant code units.

A person setting up a post schedule on a laptop, the kind of workflow a scheduling tool has to translate into correct facet byte ranges.

Where does a scheduling tool fit into this?

A scheduler that publishes to Bluesky has to build the facets array on your behalf, because the record it writes is the same app.bsky.feed.post record you would write by hand. AdaptlyPost schedules Bluesky posts alongside the other networks it publishes to, so the byte arithmetic happens once in the publishing layer rather than in every script you write. The same post can go out to multiple accounts and networks in one action, and Bluesky engagement lands in Bluesky analytics afterwards.

None of that changes the protocol. The 300 grapheme cap, the 3,000 byte cap and the UTF-8 offsets belong to Bluesky, and they apply the same way whether a tool writes the record or you do. What a tool removes is the chance of shipping an off-by-three byteStart on the one post that happened to contain an emoji.

Frequently Asked Questions

Do I need a facet if the full URL is already visible in the post text?

Yes. Bluesky does not parse links out of post text at all, so a URL typed in full renders as unclickable plain text unless a facet with a #link feature covers its byte range. This is the single most common surprise for anyone moving from an API that autolinks.

Is byteEnd the index of the last byte, or the one after it?

The one after it. The lexicon states "Start index is inclusive, end index is exclusive," which means byteEnd minus byteStart equals the exact byte length of the annotated text. For https://example.com, nineteen ASCII characters, the difference is 19.

AdaptlyPost
AdaptlyPost

Start 7-Day FREE Trial

All-platform analytics

Social Inbox

AI-powered assistant

What happens if byteStart and byteEnd are wrong?

The record is accepted. The only numeric constraint the lexicon places on either field is minimum: 0, so offsets that point at the wrong bytes, or past the end of the text, still validate. You find out by looking at the rendered post, which is why the decode-and-compare check in the code above is worth keeping.

Should I strip the hash from a tag facet?

Strip it. The lexicon says the visible text "usually includes a '#' prefix, but the facet reference should not," with one carve-out it names as "double hash tags." The byte range still covers the # in the text; only the tag string drops it.

Yes. features is defined as an array of a union of #mention, #link and #tag, so a single byteSlice can hold more than one annotation. The lexicon does not say how a client should render a range that carries two conflicting features, so keep it to one per range unless you have tested the result.

How many facets can a single post have?

The lexicon does not say. The facets array in app.bsky.feed.post has no maxLength, unlike the tags array in the same record, which is capped at maxLength: 8. Since no limit is published, treat the practical ceiling as unknown rather than assuming there is none.

What's the difference between the deprecated entities field and facets?

The entities array in app.bsky.feed.post is marked deprecated in favor of app.bsky.richtext.facet, and it used a different index type called #textSlice. textSlice counted start and end in UTF-16 code units, the same units JavaScript's indexOf and length use, while byteSlice counts UTF-8 bytes. Both definitions still sit in the lexicon files today, so a code sample that builds { start, end } is targeting the retired field, not the one Bluesky clients read now.

Why does a Bluesky mention facet store a DID instead of the handle text?

The visible text in a mention is usually a handle like @example.com, but the lexicon requires the facet reference to be a DID, resolved through the com.atproto.identity.resolveHandle query. A handle can change when someone renames their account, but a DID does not, so storing the DID keeps an old mention pointed at the right person after a rename. The query's one documented error, HandleNotFound, fires when the resolution process confirms the handle doesn't resolve to any DID.

Can the link text shown in a post differ from the actual URL?

The link feature keeps the complete URL in the facet even when the displayed text is shortened. The lexicon notes the text URL "may have been simplified or truncated," but the facet reference "should be a complete URL." That split lets a post show something like example.com/very-long... while the byte range still sends readers to the full address, as long as it covers the shortened text that was actually typed.

What are the character and byte limits on a Bluesky post?

Bluesky caps post text at 300 graphemes through maxGraphemes on the text field in app.bsky.feed.post, and separately caps it at 3,000 UTF-8 bytes through maxLength. Those are two counting units on the same field: graphemes for the display limit, bytes for the storage limit, and byteStart/byteEnd use that same byte count. An emoji or accented character eats more of the byte budget than the grapheme budget, the same gap that trips up byteStart offsets.

Was This Article Helpful?

Let us know what you think!

See us more often in Google

One click marks AdaptlyPost as a preferred source, so our articles sit higher in your Top Stories, AI Mode, and AI Overviews.

Before you go...

AdaptlyPost

AdaptlyPost

Schedule your content across all platforms

Manage all your social media accounts in one place with AdaptlyPost.

All-platform analytics

Social Inbox

AI-powered assistant

Related Glossary Terms

Related Articles