How Meta, Google, Github and others leverage HTTP conditional requests to build robust REST APIs
Deep-dive case studies in how tech giants, online retailers and one national railway company use HTTP conditional requests in their REST APIs.
Hey folks, Ilija here! 👋 Welcome to another issue of Captain’s Codebook, published on captainscodebook.com.
Every week I write advice on how to become a better product engineer, backed by my own experience of over a decade, my own research and learning from others’ experiences. Last week I wrote about:
To receive all articles and support Captain’s Codebook, consider subscribing 👇
Now, onto the latest issue…
In this issue, we will look at using HTTP conditional requests to improve our APIs’ performance and robustness. And we’ll do that by learning from eight massive organizations that use conditional requests for their HTTP APIs: Salesforce, Github, Firebase (Google), Microsoft, Zalando, Meta, and the Schweizerische Bundesbahnen (Swiss Federal Railways). These case studies will be enlightening - we'll review each organization's approach to conditional requests and their tradeoffs.
But before we go too deep, first, we'll skim over the basics - I want to give you a refresher on conditional requests so you can better enjoy the newsletter. If you are not familiar with conditional requests, I recommend reading my deep dive on the topic on my blog.
What are conditional requests in HTTP
Conditional requests are a mechanism native to HTTP, defined in RFC-7232. Conditional requests are a flexible mechanism because they enable different use cases, such as:
verifying the integrity of a document, like when resuming a download
preventing lost updates when uploading or modifying a document on the server
caching documents by avoiding sending additional bytes over the network.
Conditional requests include headers that define a precondition. An HTTP server will evaluate the request's precondition before executing the action against the target resource. Based on the precondition evaluation, the response can vary.
Validators and preconditions
Validators and preconditions are two types, or categories, of special HTTP headers:
Timestamp of last modification of the resource - the
Last-Modified
headerA unique string representing the resource version - the
ETag
(entity tag) header
The validators are attributes of the HTTP response that clients can use to build preconditions for subsequent requests to the same resource. For example, a combination of a validator with a precondition would be:
If-None-Match: W/"1117028508"
The possible precondition headers, provided by HTTP, are:
If-Match
If-None-Match
If-Modified-Since
If-Unmodified-Since
If-Range
If you’d like to go in-depth on the semantics of each of these headers, peruse section 3 of RFC-7232.
How conditional requests relate to APIs
REST API requests are nothing more than HTTP requests with additional semantics. API requests have all of HTTP’s mechanisms at their disposal, so why not put them into use?
By using conditional requests we can make the clients and the APIs more performant, and the user experience snappier. With conditional requests, clients can use the locally cached responses and APIs can avoid generating and sending those costly bytes over the network. Also, conditional requests can help APIs avoid mutation conflicts on high-throughput endpoints. Lots of potential benefits just by using a single HTTP header.
But unfortunately, in my experience, conditional requests are not widely used with APIs. So, I'd like us to change that. As you read through the different usage patterns and case studies, think about how you can apply conditional requests in your APIs.
Usage patterns
Conditional requests have two prominent roles: they can serve as a way to tell the client to use a cached response and to prevent race conditions while mutating data via an API. Of course, there are other ways to achieve the same result for complete transparency, but conditional requests are the HTTP-native approach.
Caching
The typical way you will see conditional requests used for caching is:
The client sends a request with a validator and a precondition header
The server accepts the request, uses the validator and precondition to validate the request
Based on the precondition evaluation, the server decides whether the prerequisite is satisfied or not
If the condition demands that the server must render a new response, the server obliges
If not, then the server returns an HTTP 304 Not Modified response
Let’s look at a more practical example. Imagine an API that has the /v1/users
resource. The client can use the GET /v1/users/:id
endpoint to get the user with a particular ID. For example, to fetch the user with ID=1, the client will send a GET HTTP request to the /v1/users/1
endpoint.
Let’s look at an example response:
GET /v1/users/1
{
"id": 1,
"name": "Jane",
"surname": "Doe",
"age": 20,
"location": "Oxford, England"
"last_login": 1666374784
}
This JSON represents the user Jane Doe, with ID=1. Now, when we would hit the endpoint with curl (or another HTTP client), we’d see something like this:
$ curl -X GET /v1/users/1
HTTP/2 200
date: Fri, 21 Oct 2022 19:48:44 GMT
server: api-srv-12
cache-control: no-cache
last-modified: Wed, 19 Oct 2022 15:20:24 GMT
content-type: application/json
etag: W/"1117028508"
age: 0
{
"id": 1,
"name": "Jane",
"surname": "Doe",
"age": 20,
"location": "Oxford, England"
"last_login": 1666374784
}
If you look at the bolded response headers, you’ll notice that there are two: Last-modified
and ETag
.
The Last-Modified
response header is a timestamp that indicates when the origin server believes the selected resource was last modified while handling the request. ETag
is an opaque identifier whose exact value is implementation dependent.
Case study: Github
According to Github's API documentation, their API returns an ETag response header. In addition, for some resources, the API also returns a Last-modified header. They encourage using the values of these headers to make subsequent requests to those resources using the If-None-Match
and If-Modified-Since
headers, respectively. The server will return a 304 Not Modified
if the resource has not changed.
For example, when the client fetches a user via the API:
$ curl -I https://api.github.com/user
> HTTP/2 200
> ETag: "644b5b0155e6404a9cc4bd9d8b1ae730"
> Last-Modified: Thu, 05 Jul 2012 15:31:30 GMT
The response might contain the ETag
and Last-Modified
response headers. The values of the headers allow the client to send a subsequent request to the same request using a precondition with the validator, e.g.:
$ curl -I https://api.github.com/user -H 'If-None-Match: "644b5b0155e6404a9cc4bd9d8b1ae730"'
> HTTP/2 304
> ETag: "644b5b0155e6404a9cc4bd9d8b1ae730"
> Last-Modified: Thu, 05 Jul 2012 15:31:30 GMT
If the resource hasn't changed, the ETag
value will still be valid, and the server will return just an HTTP 304 Not Modified, signaling the client that it already has the freshest representation of the resource. Another alternative is to use the If-Modifed-Since
precondition, with the timestamp from the Last-Modified
header, e.g.:
$ curl -I https://api.github.com/user -H "If-Modified-Since: Thu, 05 Jul 2012 15:31:30 GMT"
> HTTP/2 304
> Last-Modified: Thu, 05 Jul 2012 15:31:30 GMT
If the resource hasn’t changed in the meantime, the server will also return an HTTP 304 Not Modified.
Github's approach to conditional requests is excellent because receiving an HTTP 304 response does not count against the client's rate limit. Therefore, Github encourages clients to use the precondition headers as much as possible. These HTTP 304s won't matter if there's a CDN between the client and the origin API servers. If there's a CDN in place, these requests never reach Github's origin servers, and the CDN returns the HTTP 304s.
You can read more in Github’s documentation.
Case study: Salesforce Lightning Platform REST API
To support response caching, Salesforce’s REST API allows conditional request headers that follow the standards defined by the HTTP 1.1 specification. Salesforce’s API is very flexible – it supports most HTTP precondition headers: If-Match
, If-None-Match
, If-Modified-Since
, If-Unmodified-Since
. If-Range
is not permitted.
The documentation also discerns strong and weak validation, a nice little touch for the reader. Salesforce suggests using If-Match
or If-None-Match
for strong validation (via ETag
) and If-Modified-Since
and If-Unmodified-Since
for weak validation (via the Last-Modified
timestamp).
For example, using weak validation:
$ curl https://instance.salesforce.com/services/data/v52.0/limits/ -H "Authorization: Bearer token"
> HTTP/2 200
> ETag: "644b5b0155e6404a9cc4bd9d8b1ae730"
> Last-Modified: Thu, 05 Jul 2012 15:31:30 GMT
Then, you can use the value of the Last-Modified
header in the subsequent request, similarly to Github above:
$ curl https://instance.salesforce.com/services/data/v52.0/limits/ -H "Authorization: Bearer token" -H "If-Modified-Since: Thu, 05 Jul 2012 15:31:30 GMT"
> HTTP/2 304
> Last-Modified: Thu, 05 Jul 2012 15:31:30 GMT
You can read more in the API documentation.
Case study: Meta Marketing API
I found the approach that Meta took in their Marketing API interesting. Like GitHub and Salesforce above, they use the HTTP mechanics of conditional requests for their APIs.
An example of strong validation using ETag
:
$ curl -i "https://graph.beta.facebook.com/me/adaccounts?access_token=TOKEN"
> HTTP/1.1 200 OK
> Content-Type: text/javascript; charset=UTF-8
> ETag: "7776cdb01f44354af8bfa4db0c56eebcb1378975"
{"data":[{"id":"act.......
Using the ETag
from the first request in combination with If-None-Match
:
$ curl -i -H "If-None-Match: \"7776cdb01f44354af8bfa4db0c56eebcb1378975\"" "https://graph.beta.facebook.com/me/adaccounts?access_token=TOKEN"
> HTTP/1.1 304 Not Modified
> Content-Length: 0
As expected, the resource has remained unchanged so the API returned HTTP 304.
The intriguing part is batch calls: the Meta Marketing API supports batch API calls using a single HTTP call. In other words, the API multiplexes the single API call, yet it returns a single response.
For example:
$ curl -i "curl -F 'access_token=TOKEN' -F 'batch=[
{"method":"GET", "relative_url": "?ids=6003356308839,6004164369439" },
{"method":"GET", "relative_url": "act_12345678/ads?campaign_ids=[6003356307839, 6004164259439]"}]'
https://graph.facebook.com"
The above request will result in two different requests, whose responses will be combined, and each will return a separate ETag
:
...{"name":"ETag","value":"\"21d371640127490b2ed0387e8af3f0f8c9eff012\""}...
...{"name":"ETag","value":"\"410e53bb257f116e8716e4ebcc76df1c567b87f4\""}...
A client can use the above ETag
s in subsequent batch requests, just like with single requests:
$ curl -F 'access_token=TOKEN' -F 'batch=[
{"method":"GET", "headers":["If-None-Match: \"21d371640127490b2ed0387e8af3f0f8c9eff012\""], "relative_url": "?ids=6003356308839,6004164369439" },
{"method":"GET", "headers":["If-None-Match: \"410e53bb257f116e8716e4ebcc76df1c567b87f4\""], "relative_url": "act_12345678/ads?campaign_ids=[6003356307839, 6004164259439]"}]'
https://graph.facebook.com
Which can result in one or multiple responses returning an HTTP 304:
[{
"code": 304,
"body": null
},
{
"code": 304,
"body": null
}]
I found the curl syntax above a tad weird, as it's injecting JSON payload in a multipart/form-data Content-type format.
Meta suggests that the formatting might affect the ETag value, unlike the other case studies above. They've added this callout because the API generates the ETag using the complete response from the API call, including its formatting. The user agent string may impact the response format; therefore, Meta recommends "keeping your user agent consistent between calls made from the same client."
In contrast to Github's rate-limiting policies, Meta's policy states that while conditional requests using ETag
help reduce data traffic, If-None-Match
GET requests still count against the rate limits of the client. It’d be a nice touch if it didn’t, but I am sure they have a good reason to keep things as they are.
See more in their API documentation.
Optimistic Locking
We should always look out for race conditions in computing, and HTTP APIs are no exception to the rule. For example, if two clients try to modify the same resource, the client's in-memory resource might differ from the one on the server, so in case of an update, the client might overwrite prior updates done by different clients. For example, consider this flow:
In the sequence diagram above, client B overrides earlier changes done by client A. A typical race condition scenario. Luckily, HTTP conditional requests are here to save the day.
Conditional requests are a mechanism to avoid race conditions when updating a resource. HTTP already provides precondition headers that, when supplied, can stop two clients from overwriting each other's changes. Let's update the sequence diagram above to use conditional requests:
The difference between the two diagrams is the usage of preconditions and ETag
s to validate the resource's state before changing it. Both clients use ETag
s with the If-Match
header. If-Match
's semantics allow updating the resource only if the resource's state matches the provided ETag
value.
In the diagram, client A modifies the user's state with ID=1, and the API returns a different ETag
value. So, when client B tries to alter the user with the old ETag
value, the API returns a client error stating that the precondition has failed. The precondition headers and ETag
s combo are powerful for optimistic locking and avoiding race conditions.
Case study: Zalando
I love how in-depth Zalando went in this rabbit hole. It's lovely to see an organization making an opinionated but educated technical choice. Their guidelines are not the most satisfactory, but I am sure they're the best within their context and constraints. I hope, at least.
Zalando has looked at four alternatives for optimistic locking:
ETag
s withIf-Match
ETag
s in result entities, withIf-Match
Version numbers
Last-Modified
withIf-Unmodified-Since
ETag
+ If-Match
As an approach, this is identical to the sequence diagram example we saw above. For example:
$ curl -X GET /orders/BO0000042
> HTTP/1.1 200 OK
> ETag: abc123
> { "id": "BO0000042", ... }
$ curl -X PUT /orders/O0000042 -H "If-Match: abc123" -d '{ "id": "O0000042", ... }'
> HTTP/1.1 204 No Content
Or, if there was an update since the GET and the entity’s ETag
has changed:
> HTTP/1.1 412 Precondition failed
ETag
in result entities, with If-Match
Zalando provides this pattern similar to the previous one, with the core difference being that the resource result embeds the ETag
s. For example:
$ curl -X GET /orders
> HTTP/1.1 200 OK
> {
> "items": [
> { "id": "O0000042", "etag": "osjnfkjbnkq3jlnksjnvkjlsbf", "foo": 42, "bar": true },
> { "id": "O0000043", "etag": "kjshdfknjqlowjdsljdnfkjbkn", "foo": 24, "bar": false }
> ]
> }
$ curl -X PUT /orders/O0000042 -H 'If-Match: osjnfkjbnkq3jlnksjnvkjlsbf' -d '{ "id": "O0000042", "foo": 43, "bar": true }'
> HTTP/1.1 204 No Content
The ETag
for every entity is an additional attribute. In a list response (like the one above), every entity contains a distinct ETag
that users can use in subsequent PUT requests. The ETag
is read-only, and the API doesn't expect its presence in the PUT request payload.
While this works, it leaks HTTP mechanics into the resource representations. This leaking is why Zalando has to call out that ETag
is not an actual attribute that every resource has but a "special" one. It also mixes concerns - a performance optimization aspect of the API is blended into the resource representation. Lastly, how could they scale this out to Last-Modified
? Would Last-Modified
also become a response attribute? For example:
$ curl -X GET /orders
> HTTP/1.1 200 OK
> {
> "items": [
> { "id": "O0000042", "etag": "osjnfkjbnkq3jlnksjnvkjlsbf", "foo": 42, "bar": true, "last-modified": "Sat, 22 Oct 2022 19:22:16 GMT" },
> { "id": "O0000043", "etag": "kjshdfknjqlowjdsljdnfkjbkn", "foo": 24, "bar": false, "last-modified": "Fri, 21 Oct 2022 20:12:55 GMT" }
> ]
> }
Version numbers
Each entity contains a version
property. It's what it says on the lid - a version number. Think of it as version control for the entity. After an update, the API returns the entity's version
in the response. The API validates the version number – if the version
in the request is old, the API will reject the request. If it's still fresh - it will perform the update. And increment the version number.
Zalando makes an interesting semantics point here: an update of a resource with a version implies a modification of the resource by the service itself (i.e., incrementing the version); Zalando expects such endpoints to use the POST HTTP method. Practically, instead of using PUT /orders/0000042
to update the Order, their guidelines suggest using POST /orders/0000042
.
For example:
$ curl -X GET /orders
> HTTP/1.1 200 OK
> {
> "items": [
> { "id": "O0000042", "version": 1, "foo": 42, "bar": true },
> { "id": "O0000043", "version": 42, "foo": 24, "bar": false }
> ]
> }
$ curl -X POST /orders/O0000042 -d '{ "id": "O0000042", "version": 1, "foo": 43, "bar": true }'
> HTTP/1.1 204 No Content
If the resource is updated and the version number in the database is higher than the one given in the request body, the API returns a client error:
> HTTP/1.1 409 Conflict
Last-Modified
with If-Unmodified-Since
The combination of these headers is an approach that predates ETag
. Every response contains a Last-Modified
header with an HTTP date. When requesting an update using a PUT request, the client has to provide this value via the header If-Unmodified-Since
. If the Last-Modified
date of the entity is bigger than the header value, the server will reject the request.
For example:
$ curl -X GET /orders
> HTTP/1.1 200 OK
> Last-Modified: Wed, 22 Jul 2009 19:15:56 GMT
{
"items": [
{ "id": "O0000042", ... },
{ "id": "O0000043", ... }
]
}
$ curl -X PUT /block/O0000042 -H "If-Unmodified-Since: Wed, 22 Jul 2009 19:15:56 GMT" -d '{ "id": "O0000042", ... }'
> HTTP/1.1 204 No Content
Or, if there was an update of the resource or the provided dates are earlier than the last modified date:
> HTTP/1.1 412 Precondition failed
Zalando considers the ETag in result entities, with If-Match and the Last-Modified with If-Unmodified-Since superior to the rest. The ETag + If-Match option is the simplest, as ETag
s are more flexible. They don't require building custom versioning of resources, nor do they pollute the response entities themselves. Even though my preference differs from Zalando’s, I was elated to learn their REST API guidelines - I recommend reading them here.
Case study: Swiss Federal Railways
Riding on a train through Switzerland is on my bucket list. It's a lovely experience if I can judge it by all the Instagram Reels, TikToks, and photos I've seen. Breathtaking nature. Modern and tidy trains. Wonderful routes. But beyond that, the Swiss Federal Railways have another thing going well: well-documented REST API principles.
In their API principles, the Schweizerische Bundesbahnen (SBB) states that when their engineering teams build APIs they should consider supporting ETag
+ If-Match/If-None-Match
. By supporting the ETag validator plus the precondition headers, they “expose conflicts and prevent the ‘lost update’ or ‘initially created’ problem.”
I found their approach interesting because, unlike Zalando, they don't advise supporting Last-Modified
or the If-Modified-Since/If-Unmodified-Since
preconditions. But they do recommend that the ETag
is either:
a hash of the response body
a hash of the last modified field of the entity, or
a version number or identifier of the entity version
SBB doesn't make compromises like Zalando in terms of exposing versions or ETags in the response entities. But they still suggest that API should disclose this data as the ETag on the response. The suggestion is good - SBB wants to have flexible ETag values while still generating ETags opaquely.
The first and the last option are reasonable: using the entity's version number or just hashing the response body as ETag will do fine. The alternatives might appear with similar effectiveness, but they're not. Per RFC-7232, ETags should change when the observable representation of the resource changes.
Often APIs expose a limited number of entity attributes in the API. Or some fields in the API response might not exist on the entity - the backend can create them dynamically. If such a non-exposed attribute is updated, the backend will increment the version number resulting in a changed ETag. But the ETag shouldn't change. The ETag must change only when the observable state of the resource changes. Sure, if the API exposes all fields, my counter-argument is moot.
SBB's backend can keep an allowlist with eligible attributes to bump the version number to avoid bumping the version (and changing the ETag) on each update. But this is error-prone: the allowlist must be maintained and tested. Not that this characteristic makes version numbers a bad option, but it does have this pitfall. SBB decided to live with it. Good for them.
Hashing the Last-modified
datetime (option two) is the least favorable. Still, both Zalando and SBB prefer it.The problem with hashing Last-modified
is in its resolution: a second. In other words, if a single entity gets updated multiple times within a second, the response will not reflect every change in the ETag
. Sure, only some of us can boast with such high-scale traffic for this to be a problem. And maybe it's not a problem for SBB, too.
I recommend reading the rest of the Schweizerische Bundesbahnen’s API principles here.
Case study: Firebase
Firebase uses ETag
and Last-modified
to do optimistic locking on writes as well. We won't go deeper as it's similar to the other examples above. Yet, they do something the other companies don't discuss: optimistic locking on deletion.
Firebase is a set of hosting services for applications. It offers NoSQL and real-time hosting of databases, content, social authentication, notifications, or services, such as a real-time communication server. Google's investment in Firebase began as a simple NoSQL database in the cloud into a full suite of tools for hosting data for apps.
Firebase's context is essential to what we are going to discuss. Because it stores data (which is very important), Firebase provides its API clients with confidence mechanisms. Confidence is essential - clients must be sure they won't delete data whose state they're unfamiliar with. The confidence aspect is where optimistic locking on deletion comes into play: the client can delete only the data they think they're deleting.
For example, to fetch data from a location, the client can request an ETag with the X-Firebase-ETag
request header:
$ curl -i 'https://test.firebaseio.com/posts/12345/upvotes.json' -H 'X-Firebase-ETag: true'
This will return the object with an ETag
, as requested:
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: application/json; charset=utf-8
Access-Control-Allow-Origin: *
ETag: number-ten-etag
Cache-Control: no-cache
10
If the client wants to delete the data at the location, it must send a DELETE request. As with any race condition, the state of that location was 10 when the client requested it. But it can change soon. When the client wants to delete the 10 another client might've already changed it to 11. To avoid deleting changed data, the client has to supply the ETag
in the If-Match
precondition on the DELETE
request:
$ curl -X DELETE 'https://test.firebaseio.com/posts/12345/upvotes.json' -H 'if-match: number-ten-etag'
If the precondition is satisfied, the 10 will be gone. If it’s not satisfied, Firebase will reply with an HTTP 412 Precondition Failed:
HTTP/1.1 412 Precondition Failed
Content-Length: 6
Content-Type: application/json; charset=utf-8
Access-Control-Allow-Origin: *
ETag: number-twelve-etag
Cache-Control: no-cache
12 // New value of the data at the specified location
You can read more about this in Firebase’s documentation.
Limit upsert operations
For those that haven't met the term before, "upsert" is a short-hand for update+insert. In other words: update the resource if it's present; otherwise, insert it. It might be an over-generalization, but PUT methods in REST APIs should support upserting. I'll admit that this can vary per domain, so adopting the pattern is not equally spread.
Clients can use conditional requests in conjunction with upsert operations on HTTP APIs: an upsert ordinarily operates by creating an entity if it doesn't exist; otherwise, it updates an existing entity. However, ETag
s can be used to constrain upserts further to either prevent creates or to prevent updates.
Imagine a scenario where another client has deleted a resource, and our client tries to upsert the same resource again. Should the resource remain deleted? Or should we let it get recreated? Again, this complexity is manageable via ETag
s.
Case study: Microsoft’s Dataverse Web API
The Dataverse API allows control of the upserting behavior by using the If-Match
precondition header. Using the precondition, the client can ask endpoints with a default upsert behavior to work as update-only endpoints. In other words, by supplying a special If-Match
header, the API won’t create a new entry if the requested entity doesn’t exist.
For example, to turn an upsert endpoint into an update-only endpoint, you can add an If-Match
header to the request with a value of *
:
$ curl -X PATCH /api/data/v9.0/accounts(00000000-0000-0000-0000-000000000001) -H 'If-Match: *' -d '{
"name": "Updated Sample Account ",
"creditonhold": true,
"address1_latitude": 47.639583,
"description": "This is the updated description of the sample account",
"revenue": 6000000,
"accountcategorycode": 2
}'
If the account with ID 00000000-0000-0000-0000-000000000001
already exists, the API will return an HTTP 404:
HTTP/1.1 404 Not Found
OData-Version: 4.0
Content-Type: application/json; odata.metadata=minimal
{
"error": {
"code": "",
"message": "account With Id = 00000000-0000-0000-0000-000000000001 Does Not Exist"
}
}
Another example is preventing an upsert endpoint from updating when the entity is present. In other words, using the If-None-Match
precondition header, we can change the upsert endpoint only to create the entity if it doesn't exist. This behavior is useful when there is the possibility that a record with the same ID value already exists in the system, and you may not want to update it.
For example:
$ curl -X PATCH /api/data/v9.0/accounts(00000000-0000-0000-0000-000000000001) -H 'Content-Type: application/json' -H 'If-None-Match: *' -d '{
"name": "Updated Sample Account ",
"creditonhold": true,
"address1_latitude": 47.639583,
"description": "This is the updated description of the sample account",
"revenue": 6000000,
"accountcategorycode": 2
}'
The API will return a normal response with status 204 No Content
if the entity doesn't exist. However, when the entity exists, the API will return the following response with status 412 Precondition Failed
:
HTTP/1.1 412 Precondition Failed
{
"error":{
"code":"",
"message":"A record with matching key values already exists."
}
}
For complete transparency: I haven't used APIs with such mechanisms before, but I found the use case worth pointing out. You can read more about this in the Dataverse Web API documentation.
And that’s it for this week! If you liked this, consider doing any of these:
1) ❤️ Share it — Captain’s Codebook lives thanks to word of mouth. Share the article with your team or with someone to whom it might be useful!
2) ✉️ Subscribe — if you aren’t already, consider becoming a subscriber. Seeing folks read the newsletter energizes me and gives me ideas to write.
3) 🧠 Let’s chat — I am always on the lookout for questions to answer, to help out with challenges, or topic ideas to cover. Let me know what’s on your mind!
Until next time,
Ilija
It's great article, thank you!